feat/admin #2

Merged
admin merged 10 commits from feat/admin into main 2026-03-07 20:51:13 +00:00
151 changed files with 19783 additions and 163 deletions

3
.gitignore vendored
View File

@@ -31,3 +31,6 @@ yarn-error.log*
# Convex # Convex
convex/_generated convex/_generated
apps/admin/.env.staging
apps/storefront/.env.staging
convex/.env.staging

18
.mcp.json Normal file
View File

@@ -0,0 +1,18 @@
{
"mcpServers": {
"shadcn": {
"command": "npx",
"args": [
"shadcn@latest",
"mcp"
]
},
"hugeicons": {
"command": "npx",
"args": [
"-y",
"@hugeicons/mcp-server"
]
}
}
}

114
apps/admin/CLAUDE.md Normal file
View File

@@ -0,0 +1,114 @@
# Admin Dashboard UI — Implementation Rules
Applies to all UI work inside `apps/admin/`. These rules take precedence over
general project conventions for anything under this directory.
---
## 1. Component Library — ShadCN UI Only
All UI must be composed strictly from ShadCN UI components. No other component
libraries, no raw HTML element styling where a ShadCN component exists.
**Before building any UI, check if ShadCN has a component for it:**
| Category | Components |
|--------------|-----------|
| Layout | `Sheet`, `Separator`, `ScrollArea`, `ResizablePanel` |
| Navigation | `NavigationMenu`, `Breadcrumb`, `Tabs` |
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
| Forms | `Form`, `Input`, `Select`, `Checkbox`, `Switch`, `Textarea`, `RadioGroup`, `DatePicker` |
| Feedback | `Toast` (via Sonner), `Alert`, `Dialog`, `AlertDialog` |
| Loading | `Skeleton` |
| Actions | `Button`, `DropdownMenu`, `ContextMenu`, `Command` |
**Install components via the ShadCN CLI — never copy-paste or hand-write ShadCN component source:**
```bash
npx shadcn@latest add button
npx shadcn@latest add table
```
**ShadCN best practices:**
- Use `cn()` from `lib/utils.ts` for all conditional className merging — never string concatenation
- Extend ShadCN components via `className` props, never modify files in `components/ui/` directly
- Use `variant` and `size` props before reaching for custom styles
- Compose complex components by combining primitives — a stat card is `Card` + `CardHeader` + `CardContent`, not a custom div
- Use `asChild` when you need to change the rendered element (e.g. wrapping a Next.js `Link` in a `Button`)
---
## 2. Skills & MCP Usage
**Always invoke the `shadcn-ui` skill** before starting any new page or significant component:
```
/shadcn-ui
```
The skill guides intentional layout, spacing, and visual hierarchy decisions within the ShadCN constraint.
**If the ShadCN MCP server is available**, use it to look up component APIs before implementing. Do not guess prop names or variant values from memory.
**When MCP is unavailable**, refer to https://ui.shadcn.com/docs/components before writing component usage.
---
## 3. No SEO
- No `<Head>` metadata beyond the bare minimum `layout.tsx` title
- No `generateMetadata` functions on admin pages
- No Open Graph, Twitter card, or structured data tags
- No sitemap or robots.txt entries for admin routes
---
## 4. Accessibility — Required Minimums
ShadCN handles most accessibility via Radix UI primitives. Additionally ensure:
- All interactive elements are keyboard navigable (use ShadCN correctly and this is automatic)
- Form fields always have an associated `<label>` — use ShadCN `Label`; use `sr-only` if the design hides it visually
- Data tables include `scope` on `<th>` elements
- Icon-only buttons always have `aria-label` — e.g. `<Button aria-label="Delete product" size="icon"><Trash2 /></Button>`
- Modal dialogs use ShadCN `Dialog` or `AlertDialog` — never custom divs with `display:none` toggling
- Color is never the only indicator of state — always pair color with text or icon
- Focus rings must remain visible — never add `outline-none` without a replacement focus style
---
## 5. Code Quality
- Admin page components are **Server Components by default**. Add `"use client"` only when the component uses hooks, event handlers, or browser APIs
- Data fetching happens in Server Components via Convex server-side queries — not in `useEffect`
- Forms use `react-hook-form` + `zod` validation wired through ShadCN `Form` components
- Loading states use ShadCN `Skeleton` — never spinners on full page loads
- Form submit buttons **must** show a spinner while submitting. Use an inline SVG with `data-icon="inline-start"` and `animate-spin` placed before the label text, and set `disabled={isSubmitting}`. The button label should also change to reflect the in-progress state (e.g. "Creating…", "Saving…"):
```tsx
<Button type="submit" disabled={isSubmitting}>
{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 ? "Saving…" : "Save changes"}
</Button>
```
- Destructive actions (delete, archive) always use `AlertDialog` for confirmation — never `window.confirm`
- Empty states are always handled explicitly — never render an empty table or blank page silently
---
## 6. Imports
The admin app tsconfig has **no path alias**. Use relative imports only:
```typescript
// Correct
import { cn } from "../../lib/utils";
// Wrong — no @/ alias in this app
import { cn } from "@/lib/utils";
```

View File

@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "hugeicons",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View File

@@ -0,0 +1,273 @@
# Admin Dashboard — Feature Checklist
**Date:** 2026-03-04
**Audience:** Senior software engineers, project stakeholders
---
## How to Read This Document
Features are grouped into **MVP** (required for launch) and **Post-MVP** (phased rollout). Within each group, features are ordered by implementation priority.
**Legend:**
| Symbol | Meaning |
|--------|---------|
| `[ ]` | Not started |
| `[~]` | Backend exists, admin UI needed |
| `[x]` | Complete |
| **BE** | Backend work required (new Convex functions) |
| **UI** | Admin frontend work only |
| **3P** | Third-party integration required |
---
## MVP
### 1. Authentication & Authorization
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 1.1 | Clerk sign-in page (branded, in-app) | `[x]` UI | Replace hosted sign-in redirect |
| 1.2 | Admin user sync (Convex record on sign-in) | `[x]` UI | Wire existing `useStoreUserEffect` hook |
| 1.3 | Role-based auth gate (block non-admin users) | `[x]` UI | `AdminAuthGate` component, query `users.current` |
| 1.4 | Access denied page for customers | `[x]` UI | Sign-out button + storefront link |
| 1.5 | Admin layout shell (header with `UserButton`) | `[x]` UI | Persistent header with session management |
| 1.6 | Route group structure (`(auth)` vs `(dashboard)`) | `[x]` UI | Separate sign-in from protected routes |
> Full implementation plan: [05-admin-auth-implementation-plan.md](./05-admin-auth-implementation-plan.md)
---
### 2. Navigation & Layout
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 2.1 | Sidebar navigation | `[x]` UI | Collapsible; links to all admin sections |
| 2.2 | Breadcrumbs | `[x]` UI | Context-aware breadcrumb trail |
| 2.3 | Mobile-responsive admin shell | `[x]` UI | Hamburger menu on mobile, full sidebar on `lg:` |
| 2.4 | Active route highlighting | `[x]` UI | Visual indicator for current section |
---
### 3. Product Management (Inventory)
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 3.1 | Product list page | `[x]` UI | Backend: `products.list` (paginated, filterable by status/category). Build table with search, filters, pagination. |
| 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 | `[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 | `[x]` UI | Variants page with table, create/edit/preview dialogs, activate/deactivate, delete with AlertDialog. |
| 3.8 | Stock quantity editing | `[x]` UI | `stockQuantity` editable in full variant edit dialog. |
| 3.9 | Price and compare-at-price editing | `[x]` UI | `price` and `compareAtPrice` (behind On Sale toggle) editable in variant form. |
| 3.10 | Product SEO fields (title, description) | `[x]` UI | `seoTitle`, `seoDescription`, `canonicalSlug` in collapsible Advanced/SEO section. |
| 3.11 | Product search within admin | `[x]` UI | Debounced search bar on list page; switches between `products.list` and `products.search`. |
| 3.12 | Bulk status change (draft → active, etc.) | `[ ]` BE+UI | New mutation: `products.bulkUpdateStatus`. Multi-select in table. |
---
### 4. Category Management
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 4.1 | Category list / tree view | `[~]` UI | Backend: `categories.list` (supports `parentId`). Show hierarchical tree. |
| 4.2 | Create category | `[~]` UI | Backend: `categories.create`. Form: name, slug, parent, top-category slug, SEO. |
| 4.3 | Edit category | `[~]` UI | Backend: `categories.update`. |
| 4.4 | Category image upload | `[ ]` BE+UI | Schema has `imageUrl`. Need file upload flow. |
---
### 5. Order Processing
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 5.1 | Order list page | `[~]` UI | Backend: `orders.listAll` (paginated, filterable by status/paymentStatus). |
| 5.2 | Order detail page | `[~]` UI | Backend: `orders.getById` (returns items, addresses, payment info). |
| 5.3 | Update order status | `[~]` UI | Backend: `orders.updateStatus`. Dropdown or status stepper. |
| 5.4 | Cancel order (admin-initiated) | `[ ]` BE+UI | New mutation: `orders.adminCancel` — cancel regardless of customer rules, update Stripe if paid, restore stock. |
| 5.5 | Create shipping label (Shippo) | `[ ]` BE+UI+3P | New action: `shippo.createLabel` — calls Shippo Transactions API. Store `trackingNumber`, `trackingUrl`, `labelUrl` on order. |
| 5.6 | Print shipping label | `[ ]` UI+3P | Fetch label PDF URL from Shippo, open in new tab / trigger print dialog. |
| 5.7 | Track shipment status | `[ ]` BE+3P | New: Shippo tracking webhook → update order `status` and `trackingUrl`. Or poll Shippo Tracking Status API. |
| 5.8 | Refund order (full) | `[ ]` BE+UI+3P | New action: `stripe.refundPayment` — calls Stripe Refunds API. Update `paymentStatus` to `"refunded"`, `status` to `"refunded"`. |
| 5.9 | Partial refund | `[ ]` BE+UI+3P | Same Stripe Refunds API with `amount` parameter. |
| 5.10 | Return processing | `[ ]` BE+UI | New: `returns` table or status sub-flow. Accept return request → inspect → refund or reject. |
| 5.11 | Send order update email | `[ ]` BE+3P | New: email service integration (Resend or SendGrid). Triggered on status changes: confirmed, shipped (with tracking), delivered, cancelled, refunded. |
| 5.12 | Send order update SMS | `[ ]` BE+3P | New: SMS integration (Twilio or similar). Triggered on key status changes: shipped, delivered. |
| 5.13 | Order notes (internal) | `[~]` UI | Schema has `notes` field. Admin can add/edit internal notes. |
| 5.14 | Order search / filters | `[ ]` BE+UI | Search by order number, customer email, date range. May need new indexes. |
| 5.15 | Batch label creation | `[ ]` BE+UI+3P | Select multiple orders → create labels via Shippo Batches API. |
---
### 6. Customer Management (MVP-lite)
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 6.1 | Customer list page | `[~]` UI | Backend: `users.listCustomers` (paginated). |
| 6.2 | Customer detail page | `[ ]` BE+UI | New query: `users.getCustomerDetail` — user + orders + addresses. |
| 6.3 | View customer orders | `[ ]` UI | Link from customer detail to filtered order list. |
---
## Post-MVP
### 7. Dashboard & Analytics
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 7.1 | Dashboard home — key metrics | `[ ]` BE+UI | New queries: total revenue, order count, new customers (time-windowed). `DashboardStats` type already exists in `@repo/types`. |
| 7.2 | Revenue chart (daily/weekly/monthly) | `[ ]` BE+UI | New query: aggregated revenue by period. Chart library (Recharts or similar). |
| 7.3 | Orders chart | `[ ]` BE+UI | Order volume over time. |
| 7.4 | Top-selling products | `[ ]` BE+UI | New query: aggregate `orderItems` by product, sort by quantity. |
| 7.5 | Low stock alerts | `[ ]` BE+UI | New query: variants where `stockQuantity` < threshold. Dashboard widget + notification badge. |
| 7.6 | Recent orders feed | `[~]` UI | Backend: `orders.listAll` with `limit`. Real-time feed on dashboard. |
| 7.7 | Conversion funnel | `[ ]` BE+UI | Track: visits → cart adds → checkouts → completed orders. Requires analytics events. |
---
### 8. Review Management
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 8.1 | Review list page (all reviews) | `[~]` UI | Backend: `reviews.listForAdmin` (filterable by approval status, product). |
| 8.2 | Approve review | `[~]` UI | Backend: `reviews.approve`. |
| 8.3 | Delete review | `[~]` UI | Backend: `reviews.deleteReview`. |
| 8.4 | Review detail / preview | `[ ]` UI | Show full review content, images, linked product. |
| 8.5 | Bulk approve/delete | `[ ]` BE+UI | New mutations for batch operations. |
---
### 9. Customer Communication
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 9.1 | Contact form messages inbox | `[ ]` BE+UI | New `messages` table. Storefront contact form → Convex. Admin reads/replies. |
| 9.2 | Reply to customer message | `[ ]` BE+UI+3P | Send reply via email (Resend/SendGrid). Store thread in Convex. |
| 9.3 | Message status (unread/read/resolved) | `[ ]` BE+UI | Status field on messages table. |
| 9.4 | Email templates | `[ ]` BE+3P | Transactional email templates for order updates, review responses, etc. |
---
### 10. Newsletter & Marketing
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 10.1 | Newsletter subscriber list | `[ ]` BE+UI | New `subscribers` table. Storefront signup → Convex. Admin views list. |
| 10.2 | Export subscribers (CSV) | `[ ]` UI | Client-side CSV generation from subscriber list. |
| 10.3 | Compose & send newsletter | `[ ]` BE+3P | Integration with email provider (Resend/Mailchimp). Template editor. |
| 10.4 | Unsubscribe handling | `[ ]` BE | Unsubscribe link in emails → Convex mutation. |
---
### 11. Promotions & Discounts
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 11.1 | Coupon/discount code management | `[ ]` BE+UI+3P | New `coupons` table or Stripe Coupons API (already available via MCP). CRUD UI for codes, percentage/fixed amount, expiry, usage limits. |
| 11.2 | Sale tag management | `[~]` UI | Products already have `tags[]`. Admin can add/remove "sale" tag. Backend: `products.update`. |
| 11.3 | Compare-at-price (was/now pricing) | `[~]` UI | Schema has `compareAtPrice` on variants. Editable in variant management. |
---
### 12. Admin User Management
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 12.1 | Admin/staff list | `[ ]` BE+UI | New query: `users.listAdmins`. |
| 12.2 | Promote user to admin | `[ ]` BE+UI | New mutation: `users.setRole` (super_admin only). |
| 12.3 | Demote admin to customer | `[ ]` BE+UI | Same `users.setRole` mutation. |
| 12.4 | Activity / audit log | `[ ]` BE+UI | New `auditLogs` table. Log admin actions with userId, action, target, timestamp. |
---
### 13. Settings & Configuration
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 13.1 | Store settings (name, logo, contact info) | `[ ]` BE+UI | New `storeSettings` table (singleton). |
| 13.2 | Shipping configuration | `[ ]` BE+UI | Default parcel dimensions, weight limits, carrier preferences. Currently hardcoded in `model/shippo.ts`. |
| 13.3 | Tax configuration | `[ ]` BE+UI | Tax rates by region. Currently `tax` is passed manually on order creation. |
| 13.4 | Email notification preferences | `[ ]` BE+UI | Which status changes trigger emails/SMS. |
| 13.5 | Payment settings (Stripe config) | `[ ]` UI | Display Stripe connection status, webhook health. |
---
### 14. Data & Export
| # | Feature | Status | Notes |
|---|---------|--------|-------|
| 14.1 | Export orders (CSV) | `[ ]` UI | Client-side CSV from `orders.listAll`. |
| 14.2 | Export products (CSV) | `[ ]` UI | Client-side CSV from `products.listAll`. |
| 14.3 | Export customers (CSV) | `[ ]` UI | Client-side CSV from `users.listCustomers`. |
| 14.4 | Import products (CSV) | `[ ]` BE+UI | Parse CSV → batch `products.create` calls. |
---
## MVP Scope Summary
| Section | Features | New Backend Work | Third-Party |
|---------|----------|------------------|-------------|
| 1. Auth & Authorization | 6 | None | — |
| 2. Navigation & Layout | 4 | None | — |
| 3. Product Management | 12 | File upload, bulk status | — |
| 4. Category Management | 4 | File upload | — |
| 5. Order Processing | 15 | Cancel, refund, label, tracking, return, email, SMS, search, batch | Shippo, Stripe, Resend/SendGrid, Twilio |
| 6. Customer Management | 3 | Customer detail query | — |
| **Total MVP** | **44** | | |
---
## Third-Party Integration Summary
| Service | Purpose | MVP? | Existing? |
|---------|---------|------|-----------|
| **Clerk** | Authentication | Yes | Yes — sign-in, JWT, webhooks |
| **Convex** | Backend, real-time DB | Yes | Yes — full schema + functions |
| **Stripe** | Payments, refunds | Yes | Partial — checkout exists, refunds needed |
| **Shippo** | Shipping labels, tracking | Yes | Partial — rates/validation exist, labels/tracking needed |
| **Resend** or **SendGrid** | Transactional email | Yes | No — not integrated |
| **Twilio** or **SNS** | SMS notifications | Yes | No — not integrated |
| **Recharts** or **Chart.js** | Dashboard charts | Post-MVP | No |
---
## Recommended Implementation Order (MVP)
```
Phase 1 ─ Auth & Layout (1-2 days)
├─ 1.11.6 Authentication & authorization
└─ 2.12.4 Navigation & layout shell
Phase 2 ─ Product Management (3-4 days)
├─ 3.13.4 Product list, create, edit, archive
├─ 3.53.6 Image upload & gallery
├─ 3.73.9 Variant CRUD, stock, pricing
├─ 3.103.11 SEO fields, search
└─ 4.14.4 Category management
Phase 3 ─ Order Processing — Core (2-3 days)
├─ 5.15.3 Order list, detail, status update
├─ 5.4 Admin cancel
├─ 5.135.14 Order notes, search
Phase 4 ─ Shipping & Labels (2-3 days)
├─ 5.55.6 Create & print labels (Shippo)
├─ 5.7 Track shipments
└─ 5.15 Batch label creation
Phase 5 ─ Refunds & Returns (1-2 days)
├─ 5.85.9 Full & partial refund (Stripe)
└─ 5.10 Return processing
Phase 6 ─ Notifications (1-2 days)
├─ 5.11 Order update emails
└─ 5.12 Order update SMS
Phase 7 ─ Customer Management (1 day)
└─ 6.16.3 Customer list, detail, orders
```
Total estimated MVP effort: **1117 days** for a senior engineer.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,304 @@
# Navigation & Layout — Implementation Plan
**Date:** 2026-03-04
**Audience:** Senior software engineers
**Scope:** `apps/admin` — checklist section 2 (Navigation & Layout)
---
## 1. Current State
### What exists
| Component | File | Status |
|-----------|------|--------|
| `AppSidebar` | `components/layout/sidebar/app-sidebar.tsx` | Done — renders header, nav groups, footer |
| `NavMain` | `components/layout/sidebar/nav-main.tsx` | Done — collapsible nav + flat nav via `isOverview` flag |
| `NavUser` | `components/layout/sidebar/nav-user.tsx` | Done — `UserButton` + name/email, collapses with sidebar |
| `NAV_LINKS` | `lib/constants/app.constants.ts` | Done — complete route tree (overview, navMain, users) |
| Dashboard layout | `app/(dashboard)/layout.tsx` | Partial — wires `SidebarProvider` + `SidebarInset`; header slot has only `SidebarTrigger` + `Separator` |
| ShadCN UI | `components/ui/sidebar`, `collapsible`, `breadcrumb`, `separator`, `tooltip`, `skeleton` | Installed |
### What is missing
| # | Checklist item | Gap |
|---|----------------|-----|
| 2.1 | Sidebar navigation | Functional but `isActive` is hardcoded in `NAV_LINKS`; active state is not driven by current URL |
| 2.2 | Breadcrumbs | Not implemented; header slot in `(dashboard)/layout.tsx` is empty after `SidebarTrigger` |
| 2.3 | Mobile-responsive shell | ShadCN `Sidebar` handles mobile via `Sheet` — the `SidebarTrigger` in the header is the hamburger. Behaviour needs verification and a polish pass |
| 2.4 | Active route highlighting | Blocked by 2.1 — `isActive` must come from `usePathname()`, not static data |
### Architecture overview (current)
```
(dashboard)/layout.tsx
└── SidebarProvider
├── AppSidebar ← collapsible="icon"
│ ├── SidebarHeader (logo)
│ ├── SidebarContent
│ │ ├── NavMain (Platform) ← overview flat links
│ │ ├── NavMain (Application) ← collapsible groups
│ │ └── NavMain (Users) ← flat link
│ ├── SidebarFooter
│ │ └── NavUser (UserButton)
│ └── SidebarRail
└── SidebarInset
├── <header>
│ ├── SidebarTrigger ← hamburger on mobile, collapse toggle on desktop
│ └── Separator
│ └── [BREADCRUMB SLOT — empty]
└── <main>{children}</main>
```
---
## 2. Target Architecture
```
(dashboard)/layout.tsx
└── SidebarProvider
├── AppSidebar ← unchanged structure
│ ├── SidebarHeader (logo)
│ ├── SidebarContent
│ │ ├── NavMain (Platform) ← active state from usePathname()
│ │ ├── NavMain (Application) ← active state + auto-opens active group
│ │ └── NavMain (Users) ← active state from usePathname()
│ ├── SidebarFooter
│ │ └── NavUser
│ └── SidebarRail
└── SidebarInset
├── <header>
│ ├── SidebarTrigger
│ ├── Separator
│ └── DynamicBreadcrumb ← NEW: reads pathname → renders ShadCN Breadcrumb
└── <main>{children}</main>
```
---
## 3. Design Decisions
### D1: Active state via `usePathname()` — not static data
`isActive` booleans in `NAV_LINKS` are unreliable (they are snapshots, not reactive). `NavMain` should call `usePathname()` internally and derive active state at render time.
- Flat links: `isActive = pathname === item.url`
- Collapsible groups: `isActive = pathname.startsWith(item.url)` — also controls `defaultOpen`
- Sub-items: `isActive = pathname === subItem.url`
`isActive` fields in `NAV_LINKS` should be removed to avoid confusion.
### D2: Breadcrumb is a single `DynamicBreadcrumb` component
The breadcrumb reads `usePathname()`, splits into segments, maps each to a human-readable label via a static `ROUTE_LABELS` map, and renders ShadCN's `Breadcrumb` primitive.
**Segment matching rules:**
- Known static segments → label from map (e.g. `"products"``"Products"`)
- Dynamic segments (Convex IDs, slugs) → displayed as-is for now; individual detail pages can override with a `<PageBreadcrumbItem>` context pattern in a future iteration
**Home segment:** A `<House />` icon link to `/` instead of the word "Dashboard".
### D3: Mobile shell — no new code needed
ShadCN's `Sidebar` primitive (`collapsible="icon"`) renders as an `offcanvas` sheet on mobile automatically (breakpoint `md`). The `SidebarTrigger` in the layout header is already the hamburger. No structural changes required — the plan documents this explicitly so it is not re-investigated.
### D4: `NAV_LINKS` remains the single source of truth for route structure
Active state logic is moved into the component, but route definitions (titles, URLs, icons, children) stay in `NAV_LINKS`. This keeps route changes in one place.
---
## 4. Implementation Phases
### Phase 1 — Active route highlighting (2.1 / 2.4)
**Goal:** Drive `isActive` from `usePathname()` in `NavMain`. Remove hardcoded `isActive` from `NAV_LINKS`.
#### 1.1 Update `NavMain` to read `usePathname()`
`NavMain` is already a client component (`"use client"`). Add `usePathname()` from `next/navigation`.
**Flat link items (overview / users groups):**
```tsx
const pathname = usePathname();
// ...
<SidebarMenuButton isActive={pathname === item.url}>
```
**Collapsible group items:**
```tsx
const isGroupActive = pathname.startsWith(item.url);
// defaultOpen driven by isGroupActive
<Collapsible defaultOpen={isGroupActive}>
<SidebarMenuButton isActive={isGroupActive}>
// ...
{item.items?.map(subItem => (
<SidebarMenuSubButton isActive={pathname === subItem.url}>
))}
```
**Edge case — Dashboard `/` link:** Use `pathname === "/"` (exact match only) so the Dashboard link is not active on every page.
#### 1.2 Remove `isActive` from `NAV_LINKS`
Remove the `isActive: true` fields from `navMain` items in `app.constants.ts`. The type definition in `NavMain` props should also drop `isActive` from the item shape (or keep it as optional and ignore it — removing is cleaner).
#### Files changed
| File | Action |
|------|--------|
| `src/components/layout/sidebar/nav-main.tsx` | Edit — add `usePathname()`, derive `isActive` for flat and collapsible items |
| `src/lib/constants/app.constants.ts` | Edit — remove `isActive: true` from `navMain` entries |
---
### Phase 2 — Dynamic breadcrumbs (2.2)
**Goal:** Render a context-aware breadcrumb trail in the dashboard header.
#### 2.1 Create route label map
Add to `lib/constants/app.constants.ts`:
```typescript
export const ROUTE_LABELS: Record<string, string> = {
orders: "Orders",
products: "Products",
categories: "Categories",
images: "Images",
variants: "Variants",
customers: "Customers",
reviews: "Reviews",
messages: "Messages",
newsletter: "Newsletter",
users: "Users",
settings: "Settings",
returns: "Returns",
};
```
Dynamic segments (Convex IDs) that don't match a key will be displayed as-is (e.g. `"jx7abc123"` → shown literally). This is acceptable for MVP.
#### 2.2 Create `DynamicBreadcrumb` component
Create `src/components/layout/DynamicBreadcrumb.tsx`:
```
"use client"
reads usePathname()
splits into segments: "/products/categories" → ["products", "categories"]
maps each segment to ROUTE_LABELS[segment] ?? segment
renders:
- first segment: House icon link to "/"
- intermediate segments: BreadcrumbLink
- last segment: BreadcrumbPage (not a link)
- BreadcrumbSeparator between each item
```
Uses ShadCN components: `Breadcrumb`, `BreadcrumbList`, `BreadcrumbItem`, `BreadcrumbLink`, `BreadcrumbPage`, `BreadcrumbSeparator` from `@/components/ui/breadcrumb`.
**Special case — root `/`:** No segments → render only "Dashboard" as `BreadcrumbPage` (no links needed).
#### 2.3 Wire `DynamicBreadcrumb` into dashboard layout header
Edit `app/(dashboard)/layout.tsx` — the header already has `SidebarTrigger` + `Separator`. Add `<DynamicBreadcrumb />` immediately after:
```tsx
<header className="flex h-12 ...">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
<DynamicBreadcrumb />
</div>
</header>
```
#### Files changed
| File | Action |
|------|--------|
| `src/lib/constants/app.constants.ts` | Edit — add `ROUTE_LABELS` export |
| `src/components/layout/DynamicBreadcrumb.tsx` | Create |
| `src/app/(dashboard)/layout.tsx` | Edit — add `<DynamicBreadcrumb />` in header |
---
### Phase 3 — Mobile shell verification (2.3)
**Goal:** Confirm mobile sidebar behaviour works and document it. No new code expected.
#### 3.1 Verify mobile behavior
ShadCN's `Sidebar` primitive uses CSS variables and a `data-mobile` attribute (set by a `useIsMobile()` hook inside the primitive) to switch between:
- **Desktop (`lg:` and above):** Collapsible to icon rail (`collapsible="icon"`)
- **Mobile (below `lg:`):** Renders as a `Sheet` overlay; `SidebarTrigger` toggles it
The existing `(dashboard)/layout.tsx` already has `SidebarTrigger` in the header — this functions as the hamburger on mobile. No structural changes are required.
#### 3.2 What to check during implementation
- `SidebarTrigger` is visible and tappable on mobile viewports
- Sidebar opens as sheet overlay (not push) on mobile
- Nav links close the sheet on tap (ShadCN handles this via `useSidebar().setOpenMobile(false)` called inside `SidebarMenuButton` when `isMobile` is true)
- `NavUser` footer is visible and scrollable on small screens
#### Files changed
None — observation only. Document findings in `MEMORY.md` if a fix is needed.
---
## 5. File Inventory
### New files
| File | Phase | Purpose |
|------|-------|---------|
| `src/components/layout/DynamicBreadcrumb.tsx` | 2 | Context-aware breadcrumb trail |
### Modified files
| File | Phase | Change |
|------|-------|--------|
| `src/components/layout/sidebar/nav-main.tsx` | 1 | Add `usePathname()` — derive `isActive` for flat + collapsible items |
| `src/lib/constants/app.constants.ts` | 1 + 2 | Remove `isActive` from `navMain` entries; add `ROUTE_LABELS` map |
| `src/app/(dashboard)/layout.tsx` | 2 | Add `<DynamicBreadcrumb />` in header |
---
## 6. Breadcrumb Route Reference
| Path | Breadcrumb rendered |
|------|---------------------|
| `/` | Dashboard |
| `/orders` | 🏠 / Orders |
| `/orders/jx7abc` | 🏠 / Orders / jx7abc |
| `/products` | 🏠 / Products |
| `/products/categories` | 🏠 / Products / Categories |
| `/products/images` | 🏠 / Products / Images |
| `/products/variants` | 🏠 / Products / Variants |
| `/products/jx7abc` | 🏠 / Products / jx7abc |
| `/customers` | 🏠 / Customers |
| `/customers/reviews` | 🏠 / Customers / Reviews |
| `/customers/messages` | 🏠 / Customers / Messages |
| `/customers/newsletter` | 🏠 / Customers / Newsletter |
| `/users` | 🏠 / Users |
| `/settings` | 🏠 / Settings |
| `/returns` | 🏠 / Returns |
Dynamic segments (IDs/slugs not in `ROUTE_LABELS`) render their raw value. Future detail pages can introduce a breadcrumb context or slot pattern to override with a fetched entity name.
---
## 7. Implementation Order & Effort
| Phase | Description | Effort |
|-------|-------------|--------|
| 1 | Active route highlighting | ~30 min |
| 2 | Dynamic breadcrumbs | ~45 min |
| 3 | Mobile shell verification | ~15 min |
Total estimated effort: **~1.5 hours**.
Phases 1 and 2 are independent and can be worked in parallel.

View File

@@ -0,0 +1,240 @@
# Products Feature — Implementation Plan (Admin Dashboard)
**Audience:** Senior software engineers
**Scope:** Products route only — list page, create page, edit page. No images or variants.
**References:** Convex MCP, ShadCN UI MCP, `.agent/skills/shadcn-ui`, `convex/schema.ts`, `apps/admin` CLAUDE.md and admin-dashboard-ui rule.
---
## 1. Overview
Implement the Product Management feature for the admin dashboard as defined in the checklist (items 3.13.4, 3.11), limited to:
- **Product list page** — table with search, column visibility, sort, pagination, loading skeleton, row preview dialog, actions menu.
- **Create product page** — form for required and optional product fields; categories prefetched.
- **Edit product page** — same form pre-populated; archive with confirmation.
All UI must use **ShadCN UI only** (admin rule). Data is served by existing Convex `products.*` and `categories.*` APIs with small backend extensions where noted.
---
## 2. Backend (Convex) — Required Changes
### 2.1 Extend `products.create` and `products.update`
**Current state:**
`create` accepts: `name`, `slug`, `description?`, `status`, `categoryId`, `tags`.
`update` accepts: `id`, `name?`, `slug?`, `description?`, `status?`, `categoryId?`, `tags?`.
**Required:** Add optional fields so the admin form can persist a full product record (no SEO in MVP form if you prefer; otherwise add in 2.2).
| Field | Validator | Notes |
|-------|-----------|--------|
| `shortDescription` | `v.optional(v.string())` | 12 line summary |
| `brand` | `v.optional(v.string())` | e.g. "Royal Canin" |
| `attributes` | `v.optional(v.object({ petSize?, ageRange?, specialDiet?, material?, flavor? }))` | Match schema shape in `convex/schema.ts` |
**Optional (low priority):** Add `seoTitle`, `seoDescription`, `canonicalSlug` to both create and update for the “Advanced” section.
**Timestamps:** In the mutation handlers, set `createdAt: Date.now()` on insert and `updatedAt: Date.now()` on every patch so the admin can show them in the preview. Schema already has these as optional.
**Slug:** Keep slug unique by convention; no need for a separate “check slug” query if the form derives slug from name and the storefront uses it as canonical. If you want uniqueness validation, add an internal helper that checks `by_slug` before insert/update and surface a clear error.
### 2.2 Single-product fetch for admin (optional)
For the **product preview dialog**, the table already receives enriched rows from `products.list` or `products.search`. Passing the selected row into the dialog is sufficient; no extra fetch required.
If you later want to “refetch after edit” or open preview by deep link, add a public admin-only query, e.g. `products.getByIdForAdmin(id)`, that calls `requireAdmin(ctx)` and returns `getProductWithRelations(ctx, id)` (reuse existing model helper). Not required for the initial scope.
### 2.3 Sorting (optional but recommended)
`products.list` currently returns pages in index order (e.g. by status/category) with no `sortBy`/`sortOrder`. For “sort by name, brand, childCategorySlug”:
- **Option A — Client-side:** Sort the current page only (quick, no backend change).
- **Option B — Backend:** Add optional `sortBy: v.optional(v.union(v.literal("name"), v.literal("brand"), v.literal("childCategorySlug")))` and `sortOrder: v.optional(v.union(v.literal("asc"), v.literal("desc")))` to `products.list`. Use the appropriate index where possible (e.g. `by_brand`) or a single generic index and `.order()` so pagination stays consistent. Prefer Option B for correct cross-page sort.
### 2.4 Search vs list
- **Empty search:** Use `products.list({ paginationOpts, status?, categoryId? })`. Paginated; use `page`, `isDone`, `continueCursor` for the table.
- **Non-empty search:** Use `products.search({ query, status?, categoryId?, brand?, limit })`. Default `limit` is 24; for admin, pass a larger limit (e.g. 100) or document that search results are “top N” and pagination is not applied when search is active.
---
## 3. Product List Page (`apps/admin/src/app/(dashboard)/products/page.tsx`)
### 3.1 Layout (top to bottom)
1. **Title** — e.g. “Products” (heading level 1).
2. **Toolbar row** — same row, three elements:
- **Search input** — debounced (e.g. 300 ms); when empty, table uses `products.list`; when non-empty, `products.search` with admin limit. Clear button when query is non-empty.
- **Columns visibility dropdown** — control which columns are visible (see table columns below). Use ShadCN DropdownMenu + Checkbox items; persist preference in React state (or localStorage if desired).
- **Create Product button** — primary CTA; links to `/products/new` (or your create route).
3. **Data table** — ShadCN Table with sortable headers for name, brand, childCategorySlug; row click or name-cell click opens preview dialog.
4. **Pagination** — below the table; only when using `products.list` (not when showing search results). Use `continueCursor` / `isDone` from pagination result; page size selector optional (e.g. 10, 25, 50).
### 3.2 Table columns — what to show (first sight)
Prioritise what an admin needs at a glance; everything else is in the preview dialog.
| Column | Sortable | Visible by default | Notes |
|--------|----------|--------------------|--------|
| **Name** | Yes | Yes | Trigger for preview dialog (clickable). |
| **Brand** | Yes | Yes | Optional field; show “—” if empty. |
| **Child category** | Yes | Yes | `childCategorySlug` (or category name if you resolve id → name). |
| **Status** | No | Yes | Badge: active / draft / archived (colour + text). |
| **Slug** | No | Yes | For quick URL reference. |
| **Tags** | No | Optional | Comma-separated or count; hide by default in columns dropdown if too noisy. |
| **Updated** | No | Optional | `updatedAt` formatted; hide by default. |
Avoid cluttering the table with description, SEO, or attributes; those belong in the **preview dialog**.
### 3.3 Loading state — skeleton
When `products.list` or `products.search` is loading, render a **skeleton that matches the table layout**: same number of columns and a fixed number of rows (e.g. 10). Use ShadCN `Skeleton` for each cell so the table doesnt jump when data loads. No full-page spinner (per admin rules).
### 3.4 Product preview dialog
- **Trigger:** Product name cell (or whole row if you prefer; name is required for a11y).
- **Content:** Full product snapshot — all required and optional fields (name, slug, status, category, parent/child slugs, brand, tags, shortDescription, description, attributes, SEO if present, timestamps). Read-only; no form. Use ShadCN Dialog; optionally use ScrollArea for long content.
- **Data source:** Selected row from the table (no extra fetch needed for MVP).
### 3.5 Actions menu
Per row: a ShadCN DropdownMenu (e.g. “Actions” or kebab icon with `aria-label`).
- **Edit** — link to `/products/[id]/edit` (or equivalent).
- **Archive** — opens ShadCN AlertDialog: “Archive this product? It will no longer appear on the storefront.” Confirm calls `products.archive({ id })`; on success, invalidate list and close dialog.
If “Restore” (draft/active from archived) is required later, add a mutation that patches status back to `draft` and an action in the menu for archived rows.
---
## 4. Create Product Page
**Route:** e.g. `apps/admin/src/app/(dashboard)/products/new/page.tsx`.
### 4.1 Form fields (aligned with schema)
**Required (non-optional in schema):**
- `name` — text
- `slug` — text (unique; consider auto-derive from name with “Edit” override)
- `status` — select: `active` | `draft` | `archived`
- `categoryId` — select; options from prefetched categories
- `tags` — array of strings (tag input or comma-separated; allow empty `[]`)
- `parentCategorySlug` / `childCategorySlug`**do not** put in the form; backend derives them from `categoryId` in `products.create` / `products.update`
**Optional (storefront-useful):**
- `description` — textarea (rich text out of scope)
- `shortDescription` — textarea or short text
- `brand` — text
- `attributes` — structured fields (pet size, age range, diet, material, flavor) as per schema; can be a simple key-value or dedicated inputs
**System-managed (never from form):**
- `createdAt` / `updatedAt` — set in Convex only.
- `averageRating` / `reviewCount` — default 0; only changed by reviews system.
**Advanced (collapsed section):**
- `seoTitle`, `seoDescription`, `canonicalSlug` — optional; collapse under “Advanced” or “SEO” so the main form stays minimal.
### 4.2 Categories prefetch
- In the layout or page, fetch categories once (e.g. `categories.list({})` with no `parentId`, or with `parentId` if you only need leaves). Build a flat or tree structure for a select: value = `categoryId`, label = category name (and optionally slug). Use this for the **Category** dropdown on both create and edit.
### 4.3 Validation and submission
- Use **react-hook-form** with **zod**: require `name`, `slug`, `status`, `categoryId`, `tags` (array); optional fields as nullable or optional in the schema. On submit, call `products.create` with the payload; do not send `createdAt`/`updatedAt`. Redirect to list or edit page on success; toast on error.
### 4.4 Slug uniqueness
- Either derive slug from name (e.g. slugify) and rely on backend error if duplicate, or add a small Convex query that checks existence by slug and call it on blur (optional).
---
## 5. Edit Product Page
**Route:** e.g. `apps/admin/src/app/(dashboard)/products/[id]/edit/page.tsx`.
- Load product by id. If using only public APIs, use `products.list` with a filter or `products.search` and find by id, or add `products.getByIdForAdmin` (see 2.2) for a direct load. Pre-populate the same form as create; all required and optional fields that exist on the document.
- Submit calls `products.update({ id, ...updates })`. Only send changed or user-edited fields if you want partial updates; otherwise send full form state (backend ignores undefined).
- **Archive:** Same AlertDialog pattern as on the list page; after archive, redirect to list or show status updated.
---
## 6. ShadCN Components to Install
Install via CLI (no copy-paste). Example:
```bash
npx shadcn@latest add table button input dropdown-menu dialog alert-dialog badge skeleton select form label textarea checkbox
```
Ensure **Table**, **Dialog**, **AlertDialog**, **DropdownMenu**, **Form**, **Select**, **Badge**, **Skeleton**, **Button**, **Input** are available. Add **ScrollArea** for the preview dialog if content is long. Use relative imports (no `@/` in admin app).
---
## 7. File and Route Structure
- `app/(dashboard)/products/page.tsx` — list page (client component for table state, search, pagination).
- `app/(dashboard)/products/new/page.tsx` — create product (client form or server + client form).
- `app/(dashboard)/products/[id]/edit/page.tsx` — edit product.
- Shared: product form component (used by create and edit), product preview dialog component, and optionally a reusable products table (with columns configurable by columns dropdown). Place under `components/` as appropriate (e.g. `components/products/ProductForm.tsx`, `ProductPreviewDialog.tsx`, `ProductsTable.tsx`).
---
## 8. Implementation Order
1. **Backend** — Extend `products.create` and `products.update` with optional fields; set `createdAt`/`updatedAt` in mutations. Optionally add sort args to `products.list` and/or `products.getByIdForAdmin`.
2. **ShadCN** — Install required components.
3. **Categories** — Prefetch categories (query in layout or page) and pass into form; ensure category select works.
4. **List page** — Toolbar (search, columns dropdown, Create button), table with columns above, skeleton, pagination, then wire search (list vs search).
5. **Sort** — Wire sort to column headers (client-side or via backend).
6. **Preview dialog** — Name-cell trigger, read-only full product view from row data.
7. **Actions menu** — Edit link, Archive with AlertDialog and `products.archive`.
8. **Create page** — Form with required + optional fields, zod + react-hook-form, categories select, submit → `products.create`.
9. **Edit page** — Same form, load product, submit → `products.update`; archive from list or edit page.
10. **Polish** — Empty states (no products, no search results), a11y (labels, aria-labels on icon buttons), and any columns persistence.
---
## 9. Out of Scope (This Plan)
- Product images (upload, gallery, reorder).
- Variants (CRUD, stock, price).
- Bulk status change (multi-select + `products.bulkUpdateStatus`).
- SEO as first-class in the form (optional “Advanced” only).
This plan is the single reference for implementing the products feature in the admin dashboard for senior engineers; use Convex and ShadCN MCP/skills for API and component details as needed.
---
## 10. Completed Work
**Implemented:** 2026-03-05
### Backend
- [x] Extended `products.create` with optional fields: `shortDescription`, `brand`, `attributes` (petSize, ageRange, specialDiet, material, flavor), `seoTitle`, `seoDescription`, `canonicalSlug`
- [x] Extended `products.update` with the same optional fields
- [x] Both mutations set `createdAt` / `updatedAt` timestamps
- [x] Added `products.getByIdForAdmin` — admin-only query using `requireAdmin` + `getProductWithRelations`
### ShadCN Components Installed
- [x] `table`, `badge`, `skeleton`, `dropdown-menu`, `alert-dialog`, `dialog`, `scroll-area`, `form`, `select`, `input`, `textarea`, `label`, `checkbox`, `separator`, `collapsible`
### Files Created / Modified
- [x] `convex/products.ts` — extended `create`, `update`; added `getByIdForAdmin`
- [x] `src/app/(dashboard)/products/page.tsx` — full list page (search, column visibility, client-side sort, skeleton, load-more pagination, preview dialog, actions menu)
- [x] `src/components/products/ProductPreviewDialog.tsx` — read-only full-product dialog triggered from name cell
- [x] `src/components/products/ProductActionsMenu.tsx` — per-row kebab menu (Edit link + Archive with AlertDialog)
- [x] `src/components/products/ProductForm.tsx` — shared form (create + edit); zod schema, react-hook-form, auto-slug, collapsible Attributes + SEO sections, spinner on submit
- [x] `src/app/(dashboard)/products/new/page.tsx` — create product page
- [x] `src/app/(dashboard)/products/[id]/edit/page.tsx` — edit product page (pre-populated, archive button, loading/not-found states)
### Key Decisions
- Category select shows only leaf categories (those with a `parentId`) with "Parent / Child" labels
- Sort is client-side (current page only) — sufficient for the initial scope
- Submit button shows an inline SVG spinner (`data-icon="inline-start"`) during submission per updated CLAUDE.md rule
- Link-styled buttons use `<Link className={buttonVariants({...})}>` — the `Button` component's TypeScript type does not expose the `render` / `nativeButton` props from `@base-ui/react`

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

@@ -0,0 +1,276 @@
# Variants Feature — Implementation Plan (Admin Dashboard)
**Audience:** Senior software engineers
**Scope:** Variants route only — product search (shared with images page), variants table per product, create/edit dialogs, preview dialog. No images or other product management in this plan.
**References:** Convex MCP, ShadCN UI MCP, `.agent/skills/shadcn-ui`, `convex/schema.ts` (productVariants), `@repo/utils` (dollarsToCents, centsToDollars), admin CLAUDE.md and admin-dashboard-ui rule.
---
## 1. Overview
Implement the Variant Management feature for the admin dashboard (checklist items 3.7, 3.8, 3.9), limited to:
- **Variants page** (`/variant`) — title; shared product search section; on product select, fetch its variants and show them in a table with create-variant button (dialog), edit action (dialog), preview (dialog on variant name click), and actions column.
- **Shared component** — Product search (debounced, inline results, max 3, click to select) is implemented once in `apps/admin/src/components/shared` and reused on both the **Images** page and the **Variants** page.
All UI must use **ShadCN UI only**. Data is served by existing Convex `products.*` APIs with backend extensions where noted below.
---
## 2. Shared Component: Product Search Section
**Location:** `apps/admin/src/components/shared/ProductSearchSection.tsx` (or similar name).
**Behaviour (align with Images plan):**
- **Search input** — debounced (e.g. 300 ms). Query `products.search({ query, limit: 3 })`.
- **Results** — rendered **below** the input (no floating overlay). Max 3 items. Each result: **product name only**, clickable to select.
- **API:** Controlled or uncontrolled: either accept `value` / `onChange` (selected product id + product doc) from the parent, or expose a callback `onProductSelect(product)` so both Images and Variants pages can react (e.g. set selected product id and fetch images/variants).
**Contract:**
- Props: at minimum `onProductSelect: (product: { _id: Id<"products">; name: string; ... }) => void`. Optional: `placeholder`, `debounceMs`, `maxResults`.
- Use relative imports. No `@/` in admin app.
**Usage:**
- **Images page** — `<ProductSearchSection onProductSelect={...} />`; on select, fetch product (e.g. `getByIdForAdmin`) and show image gallery.
- **Variants page** — same; on select, fetch product and show variants table.
Implement this component once; refactor the Images page to use it when both features are in place (or implement it as shared from the start on the Variants page and then wire Images to it).
---
## 3. Backend (Convex) — Required Changes
### 3.1 Current API
| API | Purpose |
|-----|---------|
| `products.search` | Product search; use with limit 3 for shared search section. |
| `products.getByIdForAdmin` | Returns product with `variants` (and images, category). Use to load variants for the selected product. |
| `products.addVariant` | Create variant. See args below. |
| `products.updateVariant` | Update variant. **Currently** only accepts `id`, `price?`, `compareAtPrice?`, `stockQuantity?`, `isActive?`. |
| `products.deleteVariant` | Delete (or soft-delete if variant appears in orderItems). |
### 3.2 Extend `products.addVariant`
**Current args:** `productId`, `name`, `sku`, `price`, `compareAtPrice?`, `stockQuantity`, `attributes?`, `isActive`, `weight?`, `weightUnit?`.
Schema also has: `length?`, `width?`, `height?`, `dimensionUnit?`.
**Required:** Add optional dimension fields so the create form can persist them when the user fills dimensions.
- `length: v.optional(v.number())`
- `width: v.optional(v.number())`
- `height: v.optional(v.number())`
- `dimensionUnit: v.optional(v.union(v.literal("cm"), v.literal("in")))`
In the handler, pass these through to `ctx.db.insert("productVariants", { ... })` only when defined. Ensure `weight` / `weightUnit` remain required in practice (addVariant already defaults `weight ?? 0`, `weightUnit ?? "g"`; schema allows it).
### 3.3 Extend `products.updateVariant`
**Current args:** `id`, `price?`, `compareAtPrice?`, `stockQuantity?`, `isActive?`.
For a full **edit variant** form, the admin must be able to change name, sku, weight, attributes, dimensions, etc. Extend the mutation to accept all editable fields:
- `name: v.optional(v.string())`
- `sku: v.optional(v.string())`
- `price: v.optional(v.number())`
- `compareAtPrice: v.optional(v.number())`
- `stockQuantity: v.optional(v.number())`
- `isActive: v.optional(v.boolean())`
- `weight: v.optional(v.number())`
- `weightUnit: v.optional(v.union(v.literal("g"), v.literal("kg"), v.literal("lb"), v.literal("oz")))`
- `attributes: v.optional(v.object({ size: v.optional(v.string()), flavor: v.optional(v.string()), color: v.optional(v.string()) }))`
- `length: v.optional(v.number())`, `width: v.optional(v.number())`, `height: v.optional(v.number())`
- `dimensionUnit: v.optional(v.union(v.literal("cm"), v.literal("in")))`
Handler: patch only fields that are present in the updates object. If you introduce `sku` updates, consider enforcing uniqueness (e.g. check `by_sku` for another variant with same sku and different id).
---
## 4. Variants Page Layout and Behaviour
**Route:** `apps/admin/src/app/(dashboard)/variant/page.tsx`.
### 4.1 Structure (top to bottom)
1. **Title** — e.g. “Variants”.
2. **Product search section** — Use the shared `ProductSearchSection`. On select: store `selectedProductId` (and optionally the product doc); trigger fetch of product with variants (`products.getByIdForAdmin(selectedProductId)`).
3. **Toolbar above table (only when a product is selected):**
- Right-aligned: **Create variant** button. Click opens a **dialog** containing the create-variant form (product is fixed; `productId` is the selected product).
4. **Variants table (only when a product is selected):**
- Data: `product.variants` from `getByIdForAdmin`. No separate “list variants” query.
- Columns: show only what an admin needs at first sight (see table below). **Variant name** cell is clickable and opens a **preview dialog** with full variant data (read-only).
- **Actions column (last):** Edit (opens edit dialog), Delete (AlertDialog then `products.deleteVariant`). Use DropdownMenu or icon buttons with aria-labels.
5. **Create variant dialog** — Form in a ShadCN Dialog. On success: call `products.addVariant`, invalidate/refetch product, close dialog, toast.
6. **Edit variant dialog** — Same form fields, pre-populated from the selected variant. On success: call `products.updateVariant`, invalidate/refetch, close dialog, toast.
7. **Preview dialog** — Triggered by clicking the variant name. Read-only view of all variant fields (name, sku, price, compareAtPrice, stock, isActive, weight, weightUnit, attributes, dimensions). Use ShadCN Dialog; no form.
### 4.2 Table columns (first sight)
| Column | Notes |
|---------------|--------|
| **Name** | Clickable; opens preview dialog. |
| **SKU** | Unique identifier. |
| **Price** | Display using `formatPrice` from `@repo/utils` (cents → currency string). |
| **Compare at**| Optional; show “—” if empty. |
| **Stock** | `stockQuantity`. |
| **Status** | Badge: Active / Inactive from `isActive`. |
| **Weight** | e.g. `{weight} {weightUnit}`. |
| **Actions** | Edit, Delete (dropdown or icons). |
Avoid cluttering the table with attributes/dimensions; those are in the preview dialog.
### 4.3 Empty and loading states
- No product selected: show only title and search; optional short hint (“Search and select a product to manage variants”).
- Product selected, variants loading: table skeleton (same column count, e.g. 510 skeleton rows).
- Product selected, no variants: empty state message and prominent Create variant button.
---
## 5. Create / Edit Form — Fields and Validation
### 5.1 Required vs optional (schema-aligned)
| Field | Required | Form / mutation notes |
|-------------------|----------|------------------------|
| `productId` | Yes | Set by system; not in form (selected product). |
| `name` | Yes | Text. |
| `sku` | Yes | Text; unique (backend or zod + async check). |
| `price` | Yes | **Display in dollars**; convert to cents on submit with `dollarsToCents` from `@repo/utils`. |
| `stockQuantity` | Yes | Number; default 0 on create. |
| `isActive` | Yes | Boolean; default true on create. |
| `weight` | Yes | Number. |
| `weightUnit` | Yes | Select: `g`, `kg`, `lb`, `oz`. **Default for UK context: `kg`.** |
| `compareAtPrice` | Optional | Show only when “On Sale” toggle is on. Cents in DB; form in dollars. |
| `attributes` | Optional | size, flavor, color — only if product uses them. |
| `length` | Optional | Only if dimensions are used. |
| `width` | Optional | Same. |
| `height` | Optional | Same. |
| `dimensionUnit` | Optional | **Only show when at least one of length/width/height is set.** Default `cm`. |
### 5.2 Minimum viable variant (create)
```
name + sku + price (dollars → cents) + stockQuantity + isActive + weight + weightUnit
```
Default `stockQuantity: 0`, `isActive: true` on create.
### 5.3 Form behaviour
- **Price:** Single field in dollars (e.g. `19.99`). On submit: `dollarsToCents(value)` before sending to Convex. Never ask the admin to type cents.
- **Compare at price:** Only render this field when an “On Sale” (or “Has compare-at price”) toggle is true. Keeps the form simple when not on sale.
- **Dimension unit:** Show **only** when at least one of `length`, `width`, `height` has a value. Use `watch` on those three fields (react-hook-form); default dimension unit `cm`.
- **Dimensions group:** If one of length/width/height is filled, require all three (and dimension unit). Add a **zod refinement**: e.g. “If any dimension is set, all three and dimensionUnit must be set.”
- **Weight unit:** Default `kg` for UK pet-store context (override default in form `defaultValues`).
### 5.4 Validation (zod)
- Required: name (non-empty), sku (non-empty), price (positive number), stockQuantity (integer ≥ 0), isActive (boolean), weight (≥ 0), weightUnit (enum).
- Optional: compareAtPrice (positive number when “On Sale”), attributes (object with optional size, flavor, color), length/width/height (positive numbers), dimensionUnit (when dimensions present).
- Refinement: (length != null || width != null || height != null) ⇒ all three and dimensionUnit present.
Use `react-hook-form` with `zodResolver` and ShadCN Form/Input/Select/Switch components. Form lives inside the Create and Edit dialogs.
---
## 6. ShadCN Components
Ensure these are available (install via CLI if not):
- Table, Button, Input, Select, Label, Form (with FormField, FormItem, FormControl, FormMessage), Dialog, AlertDialog, Badge, Skeleton, DropdownMenu.
```bash
npx shadcn@latest add table button input select form dialog alert-dialog badge skeleton dropdown-menu label
```
Use relative imports. Icon buttons (e.g. Edit, Delete) must have `aria-label`.
---
## 7. File and Component Structure
- `app/(dashboard)/variant/page.tsx` — main page (client: selected product, variants data from `getByIdForAdmin`, create/edit dialog open state).
- `components/shared/ProductSearchSection.tsx` — shared product search (used by `/images` and `/variant`).
- Optional: `components/variants/VariantsTable.tsx` — table + name-cell preview trigger + actions column.
- Optional: `components/variants/VariantForm.tsx` — shared form for create and edit (props: `defaultValues`, `onSubmit`, `productId` for create).
- Optional: `components/variants/VariantPreviewDialog.tsx` — read-only full variant details.
- Optional: `components/variants/CreateVariantDialog.tsx` and `EditVariantDialog.tsx` — dialogs that wrap the form and call addVariant/updateVariant.
---
## 8. Implementation Order
1. **Shared component** — Implement `ProductSearchSection` in `components/shared`. Wire it on the Variants page with `onProductSelect`; optionally refactor Images page to use it.
2. **Backend** — Extend `addVariant` with optional length, width, height, dimensionUnit. Extend `updateVariant` with full set of editable fields (name, sku, price, compareAtPrice, stockQuantity, isActive, weight, weightUnit, attributes, dimensions). Ensure SKU uniqueness on update if needed.
3. **Variants page shell** — Title, ProductSearchSection, state for selected product. When product selected, fetch `products.getByIdForAdmin(productId)` and store product (with variants).
4. **Table** — Render variants in ShadCN Table; columns: name (clickable), sku, price (formatPrice), compareAtPrice, stock, status (Badge), weight + unit, actions. Skeleton when loading; empty state when no variants.
5. **Preview dialog** — Variant name click opens dialog; display all variant fields read-only.
6. **Create variant dialog** — Button above table; dialog with form (name, sku, price in dollars, stock, isActive, weight, weightUnit [default kg], optional compare-at when “On Sale”, optional attributes, optional dimensions with refinement). Submit → dollarsToCents(price) (and compareAtPrice if present) → `products.addVariant` → refetch, close, toast.
7. **Edit variant dialog** — Same form pre-filled; submit → `products.updateVariant` with changed fields; refetch, close, toast.
8. **Delete** — Actions column: delete with AlertDialog confirmation → `products.deleteVariant` → refetch, toast.
9. **Polish** — a11y (labels, aria-labels), error toasts, optional inline stock/price edit (if you want to keep 3.8/3.9 minimal without opening edit dialog).
---
## 9. Out of Scope (This Plan)
- Images route changes (only reuse of ProductSearchSection).
- Product create/edit or other product management.
- Bulk variant operations.
- Inline table editing (optional future: edit stock/price in cell; for now Edit opens the full form in a dialog).
This plan is the single reference for implementing the variants feature on the admin variant route for senior engineers. Use Convex and ShadCN MCP/skills for API and component details as needed.
---
## 10. Completed Implementation
### Backend (`convex/products.ts`)
- Extended `addVariant` with optional dimension fields (`length`, `width`, `height`, `dimensionUnit`) and SKU uniqueness check via `by_sku` index.
- Extended `updateVariant` from 4 fields to full set: `name`, `sku`, `price`, `compareAtPrice`, `stockQuantity`, `isActive`, `weight`, `weightUnit`, `attributes`, dimensions. SKU uniqueness enforced on change.
- Updated `getByIdForAdmin` to fetch **all** variants (not just active) using `by_product` index — admins need to see inactive variants.
### Shared Component
- `components/shared/ProductSearchSection.tsx` — moved from `components/images/` to `shared/`, fixed `@/` aliases to relative imports.
- `app/(dashboard)/images/page.tsx` — updated import to shared location.
- `lib/constants/app.constants.ts` — fixed nav link `/variants``/variant`.
### Variants Page (`app/(dashboard)/variant/page.tsx`)
- Title, product search section, `selectedProduct` state, `useQuery(getByIdForAdmin)` skipped until product selected.
- Loading skeleton (6 rows), empty state hint, product name + variant count header.
- Create variant button in toolbar (hidden while loading).
- All dialog open states managed in page: `createOpen`, `editVariant`, `previewVariant`.
### `components/variants/VariantsTable.tsx`
- 8 columns: Name (clickable → preview), SKU (monospace), Price (`formatPrice`), Compare at, Stock, Status badge, Weight, Actions.
- `VariantActionsMenu` per row: Edit, Activate/Deactivate (toggles `isActive` via `updateVariant`), Delete (AlertDialog confirmation).
- `VariantsTableSkeleton` — 6 skeleton rows for loading state.
- Empty state — "No variants yet" spanning all columns.
### `components/variants/VariantPreviewDialog.tsx`
- Read-only dialog with `ScrollArea`. Sections: Core (SKU, status badge), Pricing, Inventory, Shipping (weight + dimensions if set), Attributes (size/flavor/color if set).
### `components/variants/VariantForm.tsx`
- Zod schema with `superRefine`: "On Sale" requires `compareAtPrice`; any dimension requires all three + `dimensionUnit`.
- `optionalPositiveNum` preprocessor (cast to `z.ZodType<number | undefined>`) handles empty string → `undefined` for optional number inputs.
- Required fields: `name`, `sku`, `price` (> 0), `stockQuantity` (≥ 0), `isActive`, `weight` (> 0), `weightUnit`.
- Auto-SKU generation from `brand` + `productName` + variant attributes + weight using `generateSku` from `@repo/utils`. Only fires while `skuManuallyEdited` is false. Typing in SKU field locks it; resets to auto in create mode on `defaultValues` change.
- Edit mode: `skuManuallyEdited` starts `true` — existing SKU never overwritten.
- `variantToFormValues()` helper converts cents → dollars for price fields.
- `CREATE_DEFAULTS`: `isActive: true`, `weightUnit: "kg"`, `stockQuantity: 0`.
### `components/variants/CreateVariantDialog.tsx`
- Wraps `VariantForm` in Dialog + ScrollArea. Submit calls `products.addVariant` with `dollarsToCents` applied to price fields. Passes `productName` and `brand` for SKU generation.
### `components/variants/EditVariantDialog.tsx`
- Pre-fills form via `variantToFormValues(variant)`. Submit calls `products.updateVariant`. Passes `productName` and `brand`.
### `packages/utils/src/index.ts`
- Added `generateSku(brand, productName, attributes?, weight?, weightUnit?)` — cleans each word to 4 uppercase chars, joins with `-`.
### `app/layout.tsx`
- Added `<Toaster richColors position="bottom-right" />` (sonner). Installed `sonner` package in admin workspace.

View File

@@ -0,0 +1,236 @@
# Order Processing Backend — Phased Implementation Plan
**Audience:** Senior software engineers
**Scope:** Backend only (Convex, Stripe, Shippo). No UI.
**Source:** [Admin Order Processing User Stories](docs/project-documentation/storefront/admin-order-processing-user-stories.md).
---
## 1. Executive summary
This plan breaks the order-processing backend into four phases:
| Phase | Focus | Key deliverables |
|-------|--------|-------------------|
| **1** | Label creation | Resolve rate from shipment, POST Shippo transaction, persist label data, status → `processing`, timeline |
| **2** | Shippo Track Updated webhook | HTTP route, verify & parse, update order + timeline, set `delivered` on DELIVERED |
| **3** | Return/cancel only when confirmed | Enforce `status === "confirmed"` for customer return/cancel; optional schema for `labelUrl` |
| **4** | Refund idempotency & polish | Idempotent Stripe refund, optional tracking refresh action, tests |
**Already in place (no work in this plan):**
- **Schema:** `orders` (including `shippoShipmentId`, `shippingServiceCode`, `carrier`, `trackingNumber`, `trackingUrl`, `estimatedDelivery`, `actualDelivery`, `shippedAt`, `returnRequestedAt`, `returnReceivedAt`); `orderTimelineEvents` with `by_order` and `by_order_and_created_at`.
- **Model:** `recordOrderTimelineEvent` in `convex/model/orders.ts`.
- **Queries:** `orders.getTimeline` (timeline by order).
- **Orders:** `fulfillFromCheckout` (stores Shippo shipment ID and rate identifiers); `updateStatus` (admin) with timeline; `cancel` (customer, only when allowed) with timeline and stock restore; `requestReturn`, `markReturnReceived`, `getOrderForRefund`, `applyRefund`.
- **Refunds:** `returnActions.issueRefund` (admin, Stripe refund + `applyRefund`).
- **HTTP:** Clerk and Stripe webhooks in `convex/http.ts`.
**Resources:** Convex MCP, Stripe MCP, Shippo MCP (e.g. `shipments-get`, `transactions-create`, `tracking-status-get`, `webhooks-create`).
---
## 2. Phase 1 — Label creation (US-ADM-1, US-ADM-2)
**Goal:** Admin can create a shipping label for an order in status `confirmed`. On success, persist label data, set status to `processing` (or `shipped`), and record a timeline event. Enforce valid transitions and prevent duplicate labels.
### 2.1 Tasks
1. **Schema (optional)**
- Add `labelUrl: v.optional(v.string())` to `orders` if the team wants to store the label PDF/PNG URL from Shippo.
- If not stored, admin can open `tracking_url_provider` or use Shippo dashboard for the label.
2. **Shippo: GET shipment and resolve rate**
- In `convex/model/shippo.ts` (or a new internal helper used only by the label action):
- Add a function that calls Shippo **GET** `https://api.goshippo.com/shipments/{order.shippoShipmentId}` (or use Shippo MCP `shipments-get` from an action).
- From the response `rates` array, find the rate where `rate.servicelevel.token === order.shippingServiceCode` and `rate.provider === order.carrier`.
- Return that rates `object_id`.
- Handle: shipment not found, no matching rate, or multiple matches (pick first or fail explicitly).
3. **Shippo: Create transaction (purchase label)**
- Add a Convex **action** (e.g. in `convex/fulfillmentActions.ts` or `convex/orders.ts` with `"use node";` in a separate file if needed) that:
- Takes `orderId: v.id("orders")`.
- Runs with admin auth (e.g. resolve user via identity, then `requireAdmin`-style check via internal query).
- Loads order (internal query); validates `order.status === "confirmed"` and that the order has no `trackingNumber` yet (no label already created).
- Calls the new “resolve rate” helper to get the rate object ID from `order.shippoShipmentId`, `order.shippingServiceCode`, `order.carrier`.
- Calls Shippo **POST** `/transactions` with `{ rate: rateObjectId }` (and `async: true` for international if applicable).
- If Shippo returns async (e.g. status `QUEUED`), poll **GET** transaction until `SUCCESS` or `ERROR` (or schedule a follow-up action to poll).
- On success: extract `tracking_number`, `tracking_url_provider`, and optionally `label_url` from the transaction response.
4. **Persist label and update order**
- From the same action, call an **internal mutation** that:
- Receives `orderId`, `trackingNumber`, `trackingUrl` (from `tracking_url_provider`), optional `labelUrl`, and optional `eta` if Shippo provides it.
- Validates again that order is `confirmed` and has no existing `trackingNumber` (idempotency / duplicate-label guard).
- Patches order: `trackingNumber`, `trackingUrl`, `labelUrl` (if schema added), `estimatedDelivery` (if provided), `shippedAt: Date.now()`, `status: "processing"` (or `"shipped"` per team decision), `updatedAt: Date.now()`.
- Calls `recordOrderTimelineEvent` with `eventType: "label_created"`, `source: "admin"`, `fromStatus: "confirmed"`, `toStatus: "processing"` (or `"shipped"`), and optional `payload` (e.g. JSON with tracking number and carrier).
- On Shippo failure (rate expired, address issue, etc.): return a structured error (e.g. `{ success: false, code: "RATE_EXPIRED" | "SHIPPO_ERROR", message: string }`) so the client can show a clear message.
5. **Public API**
- Expose one public (or admin-only) **action** that orchestrates: resolve rate → create transaction → persist + timeline.
- Ensure only admins can call it (e.g. resolve user and check role in action, or call from a mutation that already enforces admin).
### 2.2 Acceptance (backend)
- Only orders in status `confirmed` and without an existing `trackingNumber` can receive a label.
- After a successful label creation, order has `trackingNumber`, `trackingUrl`, `shippedAt`, and status `processing` (or `shipped`); one `label_created` timeline event exists.
- Duplicate label creation for the same order is rejected.
- Rate resolution uses `shippoShipmentId` + `shippingServiceCode` + `carrier`; expired or missing rate returns a clear error.
### 2.3 Dependencies and references
- Shippo: [Transactions](https://docs.goshippo.com/docs/guides_general/transactions/) (POST with rate ID).
- Shippo MCP: `shipments-get` (ShipmentId = `order.shippoShipmentId`), `transactions-create` (request `{ rate: rateObjectId }`).
- Existing: `convex/model/shippo.ts` (GET shipment not present today; add or call from action via fetch/MCP).
---
## 3. Phase 2 — Shippo Track Updated webhook (US-ADM-3)
**Goal:** Receive Shippo “Track Updated” webhooks, verify and parse payload, update the order (tracking status, ETA, and on DELIVERED set `status: "delivered"`, `actualDelivery`), and append a `tracking_update` event to `orderTimelineEvents`. No manual refresh required for live tracking.
### 3.1 Tasks
1. **HTTP route**
- In `convex/http.ts`, add a route, e.g. `POST /shippo/webhook`.
- Handler: `httpAction` that reads body, optionally verifies Shippo signature (if Shippo supports it; check [Shippo webhooks](https://docs.goshippo.com/docs/tracking/tracking)), then calls an internal action with the raw body (or parsed JSON).
2. **Webhook handler action**
- Implement an **internal action** (e.g. `internal.fulfillmentActions.handleShippoTrackUpdated` or `internal.shippoWebhook.handleTrackUpdated`) that:
- Parses the JSON payload (carrier, tracking_number, tracking_status, status_details, location, eta, etc.).
- Finds the order: query by `trackingNumber` + `carrier`. Add an index on `orders` e.g. `by_tracking_number_and_carrier: ["trackingNumber", "carrier"]` for efficient lookup (only orders with a label have these set).
- If no order found, return 200 anyway (avoid retries for bad data) and log.
- If order found: call an **internal mutation** that:
- Updates the order: set latest tracking state (e.g. store a minimal `trackingStatus` / `trackingStatusDetails` on `orders` if desired for display; otherwise derive from timeline).
- Sets `estimatedDelivery` from webhook `eta` if provided.
- If tracking status is **DELIVERED**: set `order.status = "delivered"`, `actualDelivery = Date.now()`, `updatedAt = Date.now()`.
- Inserts one `orderTimelineEvents` row: `eventType: "tracking_update"`, `source: "shippo_webhook"`, `payload: JSON.stringify(webhookPayload)`, `createdAt: Date.now()`.
3. **Idempotency / duplicates**
- Shippo may send duplicate events. Options: (a) store last processed `tracking_status` + timestamp on the order and skip if same status already applied; (b) or accept duplicate timeline events and treat UI as “last event wins” for status. Prefer (a) if you want a clean timeline.
4. **Shippo webhook registration**
- Document or script: register in Shippo dashboard (or via Shippo MCP `webhooks-create`) a webhook with `event: "track_updated"` and URL `https://<deployment>.convex.site/shippo/webhook`. Use production URL and live API key for live tracking.
### 3.2 Optional: “Refresh tracking” action
- Add an **action** (admin-only) that takes `orderId`, loads order, then calls Shippo **GET** tracking (e.g. `tracks/{carrier}/{tracking_number}` or Shippo MCP `tracking-status-get`) and then updates the order + appends a `tracking_update` timeline event from the response. Use for debugging or one-off backfills; primary source remains the webhook.
### 3.3 Acceptance (backend)
- POST to `/shippo/webhook` with a valid Track Updated payload updates the correct order and appends a `tracking_update` event.
- When status is DELIVERED, order `status` becomes `delivered` and `actualDelivery` is set.
- Unknown tracking numbers or missing orders do not cause 5xx (return 200 and log).
### 3.4 Dependencies and references
- Shippo: [Track live shipments](https://docs.goshippo.com/docs/tracking/tracking), webhook payload shape.
- Shippo MCP: `webhooks-create` (event `track_updated`, url = Convex HTTP URL); `tracking-status-get` for optional refresh.
---
## 4. Phase 3 — Return / cancel only when confirmed (US-ADM-4)
**Goal:** Enforce in the backend that customer-initiated return and cancel are allowed **only** when `order.status === "confirmed"`. Align `canCustomerRequestReturn` (or equivalent) with this rule so that once the order moves to `processing`, `shipped`, or `delivered`, return/cancel from the storefront flow is rejected.
### 4.1 Tasks
1. **Cancel (already aligned)**
- `orders.cancel` already uses `canCustomerCancel(order)` which allows only `confirmed`. No change required unless you want to explicitly document or add a test that only `confirmed` is allowed.
2. **Return request**
- Today `canCustomerRequestReturn` allows return only when `order.status === "delivered"`. Per user stories, **return is only allowed while status is confirmed**.
- Update `canCustomerRequestReturn` in `convex/model/orders.ts` so that it returns `allowed: true` only when `order.status === "confirmed"` (and optionally not already cancelled/refunded/return requested).
- Adjust `orders.requestReturn` so that it uses this updated helper and rejects when not allowed.
- If the product decision is “return when confirmed = cancel”, you may keep a single cancel flow and treat “return” as cancel when confirmed; otherwise keep `requestReturn` for a distinct “return requested” state that stays in `confirmed` until admin processes it.
3. **Validation in mutations**
- Ensure any customer-facing mutation that cancels or requests a return checks the same rule: `status === "confirmed"`. Add an explicit guard at the start of `cancel` and `requestReturn` (e.g. “only confirmed orders can be cancelled/returned”) so the contract is clear.
4. **Timeline**
- When a return is requested (while confirmed), keep recording the existing `return_requested` (or equivalent) timeline event with `source: "customer_return"`.
### 4.2 Acceptance (backend)
- Customer cancel and customer return request succeed only when `order.status === "confirmed"`.
- For `processing`, `shipped`, or `delivered`, both flows return a clear error (e.g. “Return/cancel not available; contact support”).
---
## 5. Phase 4 — Refund idempotency and polish (US-ADM-5)
**Goal:** Make refunds idempotent (no double Stripe refund if admin clicks twice), and add optional tracking refresh and tests.
### 5.1 Tasks
1. **Idempotent Stripe refund**
- In `returnActions.issueRefund` (or equivalent): before calling `stripe.refunds.create`, list existing refunds for the payment intent (e.g. `stripe.refunds.list({ payment_intent: order.stripePaymentIntentId })`).
- If a refund already exists for the full amount (or the intended amount), skip creating a new one and still call `internal.orders.applyRefund` only if the order is not yet `refunded` (so order and payment status are updated once).
- If partial refunds are in scope, define the rule (e.g. “only one full refund” or “sum of refunds < total”) and implement accordingly.
2. **Order guard in applyRefund**
- In `orders.applyRefund`, at the start: if `order.paymentStatus === "refunded"` (and optionally `order.status === "refunded"`), skip patching and timeline (idempotent). This protects against double application when the action is called twice.
3. **Optional: Tracking refresh action**
- Implement the optional “Refresh tracking” action described in Phase 2 (GET Shippo tracking by carrier + tracking number, update order and append one `tracking_update` event). Secure with admin-only check.
4. **Tests**
- Unit/integration tests for: (a) label creation — only `confirmed`, no duplicate label; (b) webhook handler — find order by tracking + carrier, set delivered on DELIVERED; (c) return/cancel only when confirmed; (d) refund idempotency (second call does not create a second Stripe refund, order updated once).
### 5.2 Acceptance (backend)
- Issuing refund twice for the same order does not create two Stripe refunds and does not double-restore stock or double-write timeline.
- Refund flow is documented (full vs partial, idempotency rule).
---
## 6. Implementation order and dependencies
```
Phase 1 (Label creation) → no dependency on Phase 2/3/4
Phase 2 (Track webhook) → depends on orders having trackingNumber (from Phase 1 or manual)
Phase 3 (Return when confirmed) → independent; can run in parallel with 1 or 2
Phase 4 (Refund idempotency) → depends on existing refund flow; can run after or in parallel with 3
```
Recommended sequence: **Phase 1 → Phase 2 → Phase 3 → Phase 4**, with Phase 3 and 4 swappable.
---
## 7. File and module checklist
| Area | File / module | Changes |
|------|----------------|---------|
| Schema | `convex/schema.ts` | Optional: `orders.labelUrl`; optional: `orders.trackingStatus` / `trackingStatusDetails`; index `by_tracking_number_and_carrier` on `["trackingNumber", "carrier"]` for webhook lookup. |
| Model | `convex/model/orders.ts` | `canCustomerRequestReturn`: allow only `confirmed`; keep `recordOrderTimelineEvent`. |
| Model | `convex/model/shippo.ts` | New: get shipment by ID; resolve rate by `shippingServiceCode` + `carrier`. |
| Orders | `convex/orders.ts` | Optional: internal mutation for “apply label data + status + timeline”; guards in `requestReturn` / cancel. |
| Actions | New or existing (e.g. `convex/fulfillmentActions.ts`) | Label flow: resolve rate → POST transaction → internal mutation; optional: refresh tracking action. |
| Refunds | `convex/returnActions.ts` | Idempotent refund: list refunds, then create or skip; call `applyRefund` only when order not already refunded. |
| Refunds | `convex/orders.ts` (`applyRefund`) | Idempotent: skip if already `paymentStatus === "refunded"`. |
| HTTP | `convex/http.ts` | New route `POST /shippo/webhook` → internal action. |
| Webhook handler | New or existing (e.g. `convex/shippoWebhook.ts`) | Parse Track Updated, find order, internal mutation to update order + insert `tracking_update` event; on DELIVERED set `delivered` and `actualDelivery`. |
| Tests | `convex/orders.test.ts`, `convex/fulfillmentActions.test.ts`, etc. | Label creation rules, webhook handler, return/cancel guards, refund idempotency. |
---
## 8. Environment and configuration
- **Convex:** No new env vars required for core flow.
- **Shippo:** `SHIPPO_API_KEY` already used; ensure production webhook URL and (if applicable) webhook secret are configured for Track Updated.
- **Stripe:** Existing keys; no new config for refunds.
---
## 9. Story-to-phase mapping
| Story | Phase | Backend work |
|-------|--------|----------------|
| US-ADM-1 Create shipping label | 1 | Resolve rate, POST transaction, persist label, status, timeline |
| US-ADM-2 Order status when label printed | 1 | Same mutation sets status to `processing` (or `shipped`) |
| US-ADM-3 View tracking (live via webhook) | 2 | Shippo webhook handler; optional refresh action |
| US-ADM-4 Return only when confirmed | 3 | `canCustomerRequestReturn` + guards in cancel/requestReturn |
| US-ADM-5 Refund after successful return | 4 | Idempotent Stripe refund + idempotent `applyRefund` |
---
*Document version: 1.0. No UI work included; UI can consume the new/updated queries and actions as needed.*

View File

@@ -0,0 +1,488 @@
# Orders UI — Design Plan (Admin Dashboard)
**Audience:** Senior software engineers
**Scope:** Orders UI only — list page, detail page, all admin actions. No new backend work.
**Backend reference:** `06-order-processing-backend-implementation-plan.md`
**Design constraint:** Match the existing admin design language exactly (ShadCN only, HugeIcons, same layout shell, same patterns as Products page).
---
## 1. Design philosophy
Three principles guide every decision here:
1. **Single responsibility per component.** Each component owns one concern: a card, a table, a button, a timeline. No component manages both data display and action side-effects unless it is a leaf action component.
2. **Status-driven UI.** The order's `status` field is the single source of truth. What you see, what buttons appear, what badges render — all computed from status. No hidden state, no guessing.
3. **Consistency over novelty.** Use the exact same patterns already in the codebase: `rounded-lg border` for table wrappers, `text-xl font-semibold` headings, `usePaginatedQuery` + "Load more", `AlertDialog` for destructive actions, `toast` (Sonner) for feedback.
---
## 2. Route structure
```
/orders → OrdersPage (list)
/orders/[id] → OrderDetailPage (detail)
```
The detail page is a full Next.js route (`app/(dashboard)/orders/[id]/page.tsx`), not a dialog. Orders are complex enough to deserve their own page; a dialog would feel cramped.
---
## 3. Status vocabulary
Both pages share the same two badge components. Define color intent once, reuse everywhere.
### 3.1 Order status badges
| Status | Badge variant | Tailwind override | Meaning |
|--------|--------------|-------------------|---------|
| `pending` | `secondary` | — | Payment not yet confirmed |
| `confirmed` | `outline` | `border-blue-500 text-blue-600` | Paid, awaiting fulfilment |
| `processing` | `outline` | `border-violet-500 text-violet-600` | Label created, packing |
| `shipped` | `outline` | `border-indigo-500 text-indigo-600` | In transit |
| `delivered` | `default` | `bg-green-600` | Delivered |
| `cancelled` | `destructive` | — | Cancelled |
| `refunded` | `secondary` | `line-through` | Refunded |
### 3.2 Payment status badges
| Status | Badge variant | Tailwind override |
|--------|--------------|-------------------|
| `pending` | `secondary` | — |
| `paid` | `default` | `bg-green-600` |
| `failed` | `destructive` | — |
| `refunded` | `secondary` | — |
Both mappings live in `apps/admin/src/components/orders/shared/statusConfig.ts` — a plain TypeScript constants file, no JSX.
---
## 4. Orders list page
**File:** `apps/admin/src/app/(dashboard)/orders/page.tsx`
### 4.1 Layout (top to bottom)
```
┌─────────────────────────────────────────────────┐
│ Orders │
├─────────────────────────────────────────────────┤
│ [🔍 Search by order # or email…] [Status ▾] │
├─────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────┐ │
│ │ Order # │ Customer │ Date │ Status │ Total │ │
│ │──────────┼──────────┼──────┼────────┼───────│ │
│ │ ORD-AB12 │ Jane Doe │ … │ [Conf] │ £42 │ │
│ └─────────────────────────────────────────────┘ │
│ 12 loaded [Load more] │
└─────────────────────────────────────────────────┘
```
### 4.2 Data fetching
- **Default (no filter, no search):** `usePaginatedQuery(api.orders.listAll, {}, { initialNumItems: 25 })`
- **Status filter active:** `usePaginatedQuery(api.orders.listAll, { status }, { initialNumItems: 25 })`
- Pagination: "Load more" button + count footer — identical to ProductsPage.
- No search query for now (orders list does not have a full-text search index). Search by order number or email is client-side filtered from the loaded page; add a note in the component that server-side search can be added when an index exists.
### 4.3 Table columns
| Column | Notes |
|--------|-------|
| Order # | `font-mono text-xs`; clicking the row navigates to detail |
| Customer | `order.email`; `text-muted-foreground` |
| Date | `createdAt` formatted short; `text-xs text-muted-foreground` |
| Status | `<OrderStatusBadge status={order.status} />` |
| Payment | `<OrderPaymentBadge status={order.paymentStatus} />` |
| Total | `formatPrice(order.total)` right-aligned |
No actions menu column — clicking anywhere on the row navigates to the detail page. Keeps the list clean. A chevron icon on the right of each row communicates navigability.
### 4.4 Extracted components
| Component | File | Responsibility |
|-----------|------|---------------|
| `OrderStatusBadge` | `components/orders/shared/OrderStatusBadge.tsx` | Renders one `<Badge>` for order status |
| `OrderPaymentBadge` | `components/orders/shared/OrderPaymentBadge.tsx` | Renders one `<Badge>` for payment status |
| `OrdersTableSkeleton` | inline in list page | 10 skeleton rows, same columns |
### 4.5 Empty and loading states
- **Loading:** 10 skeleton rows with matching column widths.
- **Empty (no orders):** Centered row: *"No orders yet."*
- **Empty (filter applied):** *"No orders with status 'confirmed'."*
---
## 5. Order detail page
**File:** `apps/admin/src/app/(dashboard)/orders/[id]/page.tsx`
This page uses `useQuery(api.orders.getById, { id })` and `useQuery(api.orders.getTimeline, { orderId: id })` to load both the order and its timeline in parallel.
### 5.1 Layout
Two-column layout on `md+`, stacked on mobile. The left column is wider (about 60%) and holds the primary data. The right column holds contextual cards.
```
┌──────────────────────┬───────────────────┐
│ │ │
│ ← Orders │ Customer │
│ ORD-AB12 [Conf] │ Jane Doe │
│ 14 Jan 2026 │ jane@example.com │
│ ├───────────────────┤
│ [Admin actions bar] │ Shipping address │
│ │ 123 Main St… │
├──────────────────────┤ │
│ Order items ├───────────────────┤
│ ────────────────── │ Fulfilment │
│ Product Qty Total │ Carrier: DPD UK │
│ … │ Service: Next Day│
├──────────────────────┤ [Create Label] │
│ Financials ├───────────────────┤
│ Subtotal £38.00 │ │
│ Shipping £4.99 │ (empty if no │
│ Total £42.99 │ tracking yet) │
├──────────────────────┤ │
│ Timeline │ │
│ ○ Order confirmed │ │
│ ○ Label created │ │
│ ○ Delivered │ │
└──────────────────────┴───────────────────┘
```
On mobile, right column cards stack below the left column, in this order: Customer → Shipping → Fulfilment.
### 5.2 Loading state
All cards render as `Skeleton` blocks while data is `undefined`. Each card has its own skeleton shape so the layout does not shift when data loads. No full-page spinner.
### 5.3 Component breakdown
Every component below has **one job**. None of them fetch data themselves — the page fetches and passes props down.
---
#### 5.3.1 `OrderPageHeader`
**File:** `components/orders/detail/OrderPageHeader.tsx`
**Job:** Back link, order number, date, status badges.
```
← Orders ORD-AB12 [Confirmed] [Paid] 14 Jan 2026, 10:22
```
Props: `orderNumber`, `createdAt`, `status`, `paymentStatus`.
No actions here. Badges are read-only. Headings use `text-xl font-semibold`.
---
#### 5.3.2 `OrderActionsBar`
**File:** `components/orders/detail/OrderActionsBar.tsx`
**Job:** Render the correct set of action buttons based on the order's current status. This is the only component that "knows" which actions are valid — it reads `status`, `paymentStatus`, `trackingNumber`, `returnRequestedAt`, `returnReceivedAt` and decides what to render.
Action matrix:
| Condition | Buttons shown |
|-----------|--------------|
| `status === "confirmed"` and no `trackingNumber` | **Create Label** (primary), **Update Status** (outline) |
| `status === "confirmed"` and `trackingNumber` exists | **Update Status** (outline) |
| `status === "confirmed"` and `returnRequestedAt` and no `returnReceivedAt` | **Mark Return Received** (outline), **Update Status** |
| `status === "confirmed"` and `returnReceivedAt` and `paymentStatus !== "refunded"` | **Issue Refund** (destructive), **Update Status** |
| `status === "processing"` or `"shipped"` | **Update Status** only |
| `status === "delivered"` and `paymentStatus !== "refunded"` | **Issue Refund** (destructive), **Update Status** |
| `status === "cancelled"` or `"refunded"` | Read-only — no buttons |
**`OrderActionsBar` does not implement the action logic itself.** It renders the individual action components below. It is purely a conditional layout shell.
---
#### 5.3.3 `CreateLabelButton`
**File:** `components/orders/actions/CreateLabelButton.tsx`
**Job:** Call `api.fulfillmentActions.createShippingLabel`, show loading spinner, surface structured errors.
- Uses `useAction(api.fulfillmentActions.createShippingLabel)`.
- On click: sets loading, calls action, on success shows `toast.success("Label created. Tracking: {trackingNumber}")`, on structured error shows `toast.error(result.message)`, on thrown error shows generic toast.
- Button label: "Create Label" → "Creating…" while loading.
- Disabled while loading.
- No AlertDialog — creating a label is not destructive.
---
#### 5.3.4 `UpdateStatusDialog`
**File:** `components/orders/actions/UpdateStatusDialog.tsx`
**Job:** A `Dialog` containing a `Select` of valid next statuses. On confirm, calls `api.orders.updateStatus`.
- Triggered by "Update Status" button in `OrderActionsBar`.
- Select shows all statuses except the current one.
- Submit button shows spinner while mutating.
- On success: `toast.success("Status updated to {status}")`.
---
#### 5.3.5 `MarkReturnReceivedButton`
**File:** `components/orders/actions/MarkReturnReceivedButton.tsx`
**Job:** Call `api.orders.markReturnReceived` with an `AlertDialog` confirmation.
- AlertDialog title: *"Mark return as received?"*
- Description: *"Confirm that the returned items have arrived. You can then issue a refund."*
- Uses `useMutation(api.orders.markReturnReceived)`.
- Shows spinner on the confirm button while loading.
---
#### 5.3.6 `IssueRefundButton`
**File:** `components/orders/actions/IssueRefundButton.tsx`
**Job:** Call `api.returnActions.issueRefund` with an `AlertDialog` confirmation.
- AlertDialog title: *"Issue full refund?"*
- Description: *"A full refund of £{order.total} will be sent to the customer via Stripe. This cannot be undone."*
- Uses `useAction(api.returnActions.issueRefund)`.
- Confirm button: destructive variant, "Issue Refund" → "Refunding…".
- On success: `toast.success("Refund issued")`.
- On error: `toast.error(err.message)`.
---
#### 5.3.7 `OrderItemsCard`
**File:** `components/orders/detail/OrderItemsCard.tsx`
**Job:** Display the line items in a `Card` with a bordered table inside.
Columns: Product name (+ variant name below in `text-xs text-muted-foreground`), SKU (`font-mono text-xs`), Qty, Unit price, Total.
No actions. Read-only.
```
┌─ Order items ────────────────────────────────────┐
│ Product SKU Qty Unit Total │
│ Royal Canin… │
│ Adult 2kg SKU-001 2 £19.00 £38.00 │
└──────────────────────────────────────────────────┘
```
---
#### 5.3.8 `OrderFinancialsCard`
**File:** `components/orders/detail/OrderFinancialsCard.tsx`
**Job:** Render the price breakdown.
Uses `InfoRow`-style layout (label left, value right) inside a `Card`. Subtotal, shipping, discount (if > 0), tax (if > 0), and a `Separator` before the bold total.
---
#### 5.3.9 `CustomerCard`
**File:** `components/orders/detail/CustomerCard.tsx`
**Job:** Display the customer name and email.
`Card` with `CardHeader` (title "Customer") and `CardContent`. Email is a `mailto:` link styled as `text-sm text-muted-foreground hover:underline`.
---
#### 5.3.10 `ShippingAddressCard`
**File:** `components/orders/detail/ShippingAddressCard.tsx`
**Job:** Render the `shippingAddressSnapshot` in a `Card`.
Displays each address line in `text-sm`, country in `text-xs text-muted-foreground`. No editing — it's a snapshot.
---
#### 5.3.11 `FulfilmentCard`
**File:** `components/orders/detail/FulfilmentCard.tsx`
**Job:** Shipping service info + tracking state. No action buttons — those live in `OrderActionsBar`.
Three visual states:
**State A — No label yet (status: `confirmed`, no `trackingNumber`):**
```
┌─ Fulfilment ──────────────────────────────────┐
│ Carrier DPD UK │
│ Service Next Day │
│ Method Express │
│ │
│ No label created yet. │
└───────────────────────────────────────────────┘
```
**State B — Label created, in transit:**
```
┌─ Fulfilment ──────────────────────────────────┐
│ Carrier DPD UK │
│ Service Next Day │
│ Tracking 1Z999AA1… [↗ Track] │
│ Status TRANSIT │
│ Est. delivery 18 Jan 2026 │
│ Label [↗ Download] │
└───────────────────────────────────────────────┘
```
**State C — Delivered:**
```
┌─ Fulfilment ──────────────────────────────────┐
│ Carrier DPD UK │
│ Tracking 1Z999AA1… [↗ Track] │
│ Status DELIVERED │
│ Delivered 17 Jan 2026, 14:35 │
└───────────────────────────────────────────────┘
```
The `trackingStatus` field drives the displayed status string. Tracking and label URLs open in a new tab.
---
#### 5.3.12 `OrderTimelineCard`
**File:** `components/orders/detail/OrderTimelineCard.tsx`
**Job:** Render `orderTimelineEvents` as a vertical stepper/timeline.
Receives the `events` array as a prop (fetched by the page).
Each event row:
```
○ Status changed: confirmed → processing 14 Jan · 10:35
by admin
```
Event type → human label mapping:
| `eventType` | Display label |
|-------------|--------------|
| `status_change` | "Status changed: {fromStatus} → {toStatus}" |
| `label_created` | "Shipping label created" + tracking number from payload |
| `tracking_update` | "Tracking update: {status}" |
| `customer_cancel` | "Customer requested cancellation" |
| `return_requested` | "Customer requested return" |
| `return_received` | "Return marked as received" |
| `refund` | "Refund issued" |
The dot (`○`) color matches the status badge color for `status_change` events; neutral for all others.
Loading state: 34 skeleton rows.
Empty state: *"No activity yet."*
---
## 6. Shared utilities
**File:** `components/orders/shared/statusConfig.ts`
```typescript
// Order status → { label, badgeVariant, className }
// Payment status → { label, badgeVariant, className }
// Event type → human label
// Timeline dot color by toStatus
```
Keep all display constants here. Both pages and all badge components import from this single file. No duplication.
---
## 7. File tree
```
apps/admin/src/
├── app/(dashboard)/
│ └── orders/
│ ├── page.tsx ← Orders list page
│ └── [id]/
│ └── page.tsx ← Order detail page
└── components/
└── orders/
├── shared/
│ ├── statusConfig.ts ← Constants only, no JSX
│ ├── OrderStatusBadge.tsx
│ └── OrderPaymentBadge.tsx
├── detail/
│ ├── OrderPageHeader.tsx
│ ├── OrderActionsBar.tsx ← Layout shell, no action logic
│ ├── OrderItemsCard.tsx
│ ├── OrderFinancialsCard.tsx
│ ├── CustomerCard.tsx
│ ├── ShippingAddressCard.tsx
│ ├── FulfilmentCard.tsx
│ └── OrderTimelineCard.tsx
└── actions/
├── CreateLabelButton.tsx
├── UpdateStatusDialog.tsx
├── MarkReturnReceivedButton.tsx
└── IssueRefundButton.tsx
```
---
## 8. Data flow
```
OrderDetailPage (page.tsx)
│ useQuery(api.orders.getById, { id }) → order
│ useQuery(api.orders.getTimeline, { orderId }) → events
├── OrderPageHeader (order)
├── OrderActionsBar (order)
│ ├── CreateLabelButton (orderId, order.status, order.trackingNumber)
│ ├── UpdateStatusDialog (orderId, order.status)
│ ├── MarkReturnReceivedButton (orderId)
│ └── IssueRefundButton (orderId, order.total, order.currency)
├── [left column]
│ ├── OrderItemsCard (order.items)
│ ├── OrderFinancialsCard (order)
│ └── OrderTimelineCard (events)
└── [right column]
├── CustomerCard (order.email, order.userId)
├── ShippingAddressCard (order.shippingAddressSnapshot)
└── FulfilmentCard (order)
```
All queries are read-only in the page. Action components call mutations/actions themselves and handle their own loading/error state. The page never passes mutation functions as props.
---
## 9. Implementation order
| Step | What to build | Why first |
|------|---------------|-----------|
| 1 | `statusConfig.ts`, `OrderStatusBadge`, `OrderPaymentBadge` | Shared atoms used by both pages |
| 2 | Orders list page | Validates data fetching works, gives navigation to detail |
| 3 | `OrderPageHeader`, `OrderItemsCard`, `OrderFinancialsCard` | Core detail page scaffold — read-only |
| 4 | `CustomerCard`, `ShippingAddressCard`, `FulfilmentCard` | Right column — all read-only |
| 5 | `OrderTimelineCard` | Needs timeline query wired |
| 6 | `UpdateStatusDialog` | Lowest-risk action — any status |
| 7 | `CreateLabelButton` | Most impactful Phase 1 action |
| 8 | `MarkReturnReceivedButton`, `IssueRefundButton` | Phase 3/4 actions |
| 9 | `OrderActionsBar` | Wire all actions together with condition logic |
Build read-only views first, add write actions last. This way the detail page is useful even before all actions are wired.
---
## 10. Design decisions and rationale
**Why a full page for detail, not a dialog?**
Orders have 5+ cards, a timeline, and multiple action flows. A dialog forces vertical scrolling with no space for the two-column layout. A page also makes deep-linking (e.g. linking from a customer email) trivial.
**Why does `OrderActionsBar` not implement actions?**
If it did, adding or changing one action would require modifying a large, complex component. With leaf action components, each action is independently testable and replaceable.
**Why no search on the list page?**
The `orders` table has no full-text search index. Client-side filtering over 25 loaded rows is fine for a small operation, but it does not scale past the first page. A future iteration can add a `by_order_number` or `by_email` query when the need is clear.
**Why `useAction` for `createShippingLabel` and `issueRefund`?**
Both are Convex actions (they call external APIs). `useAction` from `convex/react` is the correct hook — not `useMutation`.
**Why show `trackingStatus` in `FulfilmentCard` rather than a separate card?**
Tracking info and fulfilment info are tightly related; an admin needs them together. Separating them would force eye-scanning across two cards to understand the delivery state.
---
*Document version: 1.0. UI only — no new backend work required.*

View File

@@ -0,0 +1,189 @@
# API Usage Guide
This document describes how to use the background removal API.
---
## Base URL
- Local: `http://localhost:8000`
- Production: `http://localhost:8000`
---
## Endpoints
### 1. API Info
```
GET /
```
Returns service metadata and links.
**Response:**
```json
{
"service": "withoutbg-api",
"version": "1.0.2",
"docs": "/docs",
"health": "/api/health"
}
```
---
### 2. Health Check
```
GET /api/health
```
Verify the API is running and models are loaded.
**Response:**
```json
{
"status": "healthy",
"version": "1.0.2",
"service": "withoutbg-api",
"models_loaded": true
}
```
---
### 3. Remove Background
```
POST /api/remove-background
Content-Type: multipart/form-data
```
Remove the background from an image and return the result.
#### Parameters
| Name | Type | Required | Default | Description |
|---------|--------|----------|---------|------------------------------------------------------------|
| `file` | File | Yes | — | Image file (PNG, JPEG, WebP) |
| `format`| String | No | `webp` | Output format: `webp`, `png`, or `jpg` |
| `quality` | Integer | No | `95` | Compression quality for WebP/JPEG (1100) |
#### Request Example (curl)
```bash
# WebP output (default)
curl -X POST https://your-api.com/api/remove-background \
-F "file=@/path/to/photo.jpg" \
-o result.webp
# PNG output
curl -X POST https://your-api.com/api/remove-background \
-F "file=@photo.png" \
-F "format=png" \
-o result.png
# JPEG output with quality
curl -X POST https://your-api.com/api/remove-background \
-F "file=@photo.jpg" \
-F "format=jpg" \
-F "quality=90" \
-o result.jpg
```
#### Request Example (Python)
```python
import requests
url = "https://your-api.com/api/remove-background"
with open("photo.jpg", "rb") as f:
response = requests.post(
url,
files={"file": ("photo.jpg", f, "image/jpeg")},
data={"format": "webp", "quality": 95},
)
if response.ok:
with open("result.webp", "wb") as out:
out.write(response.content)
else:
print(response.json())
```
#### Request Example (JavaScript / Fetch)
```javascript
const formData = new FormData();
formData.append("file", imageFile);
formData.append("format", "webp");
formData.append("quality", "95");
const response = await fetch("https://your-api.com/api/remove-background", {
method: "POST",
body: formData,
});
if (response.ok) {
const blob = await response.blob();
// Use blob (e.g. create download link, display in img)
} else {
const error = await response.json();
console.error(error.detail);
}
```
#### Response
- **Success (200):** Binary image (WebP, PNG, or JPEG)
- **Content-Type:** `image/webp`, `image/png`, or `image/jpeg`
- **Content-Disposition:** `inline; filename=withoutbg.{format}`
#### Response Headers
| Header | Description |
|-----------------------|-----------------------------------------------------------------------------|
| `X-Format-Fallback` | Present if WebP was requested but PNG was returned (e.g. `X-Format-Fallback: png`) |
---
## Processing Behavior
- Input image is centered on a transparent square canvas (height × height)
- Output is resized so the longest side is at most 1000px
- Output size is limited to ~50KB; if larger, the API may compress further
- Supported input formats: PNG, JPEG, WebP
---
## Error Responses
Errors return JSON with a `detail` field.
| Status | Meaning | Example `detail` |
|--------|----------------------------|-----------------------------------------|
| 400 | Bad request | `File too small (64 bytes)...` |
| 400 | Invalid image | `Invalid or unsupported image format` |
| 500 | Processing error | `Processing failed: ...` |
| 503 | Service not ready | `Models not loaded. Server may still be starting up.` |
**Example:**
```json
{
"detail": "File too small (64 bytes). Verify the file exists and use full path: -F 'file=@/path/to/image.webp'"
}
```
---
## Interactive Documentation
When the API is running, Swagger UI is available at:
```
GET /docs
```
Use it to try endpoints from the browser.

6
apps/admin/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

19
apps/admin/next.config.js Normal file
View File

@@ -0,0 +1,19 @@
const path = require("path");
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"],
turbopack: {
root: path.join(__dirname, "..", ".."),
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "res.cloudinary.com",
},
],
},
};
module.exports = nextConfig;

42
apps/admin/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "admin",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"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",
"@repo/convex": "*",
"@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",
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.1",
"zod": "^3.25.76"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.0",
"postcss": "^8.4.0",
"shadcn": "^3.8.5",
"tailwindcss": "^4.2.0",
"tw-animate-css": "^1.4.0"
}
}

View File

@@ -0,0 +1,5 @@
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
},
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 60 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 75 KiB

View File

@@ -0,0 +1,9 @@
import { SignIn } from "@clerk/nextjs";
export default function SignInPage() {
return (
<main className="flex min-h-screen items-center justify-center">
<SignIn />
</main>
);
}

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/shared/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,42 @@
import { AdminUserSync } from "../../components/auth/AdminUserSync";
import { AdminAuthGate } from "../../components/auth/AdminAuthGate";
import { AppSidebar } from "../../components/layout/sidebar/app-sidebar";
import { DynamicBreadcrumb } from "../../components/layout/DynamicBreadcrumb";
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<AdminUserSync />
<AdminAuthGate>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-4 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<DynamicBreadcrumb />
</div>
</header>
<main className="flex flex-1 flex-col gap-4 p-4">
{children}
</main>
</SidebarInset>
</SidebarProvider>
</AdminAuthGate>
</>
);
}

View File

@@ -0,0 +1,143 @@
"use client"
import { useQuery } from "convex/react"
import { useParams } from "next/navigation"
import { api } from "../../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../../convex/_generated/dataModel"
import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { OrderPageHeader } from "@/components/orders/detail/OrderPageHeader"
import { OrderItemsCard } from "@/components/orders/detail/OrderItemsCard"
import { OrderFinancialsCard } from "@/components/orders/detail/OrderFinancialsCard"
import { CustomerCard } from "@/components/orders/detail/CustomerCard"
import { ShippingAddressCard } from "@/components/orders/detail/ShippingAddressCard"
import { FulfilmentCard } from "@/components/orders/detail/FulfilmentCard"
import { OrderTimelineCard } from "@/components/orders/detail/OrderTimelineCard"
import { OrderActionsBar } from "@/components/orders/detail/OrderActionsBar"
// ─── Per-card skeletons (no layout shift on load) ─────────────────────────────
function HeaderSkeleton() {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-20" />
<div className="flex items-center gap-2">
<Skeleton className="h-7 w-32" />
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-4 w-36" />
</div>
</div>
)
}
function CardSkeleton({ rows = 3 }: { rows?: number }) {
return (
<Card>
<CardHeader>
<Skeleton className="h-5 w-28" />
</CardHeader>
<CardContent className="flex flex-col gap-3">
{Array.from({ length: rows }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</CardContent>
</Card>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function OrderDetailPage() {
const params = useParams()
const orderId = params.id as Id<"orders">
const order = useQuery(api.orders.getById, { id: orderId })
// Timeline query is wired here for Phase 5 — passed to OrderTimelineCard
const events = useQuery(api.orders.getTimeline, { orderId })
// ── Loading state ───────────────────────────────────────────────────────────
if (order === undefined) {
return (
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
<HeaderSkeleton />
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_340px]">
{/* Left column */}
<div className="flex flex-col gap-6">
<CardSkeleton rows={4} />
<CardSkeleton rows={4} />
<CardSkeleton rows={5} />
</div>
{/* Right column */}
<div className="flex flex-col gap-6">
<CardSkeleton rows={2} />
<CardSkeleton rows={3} />
<CardSkeleton rows={4} />
</div>
</div>
</main>
)
}
return (
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
{/* Header — back link, order#, date, status badges */}
<OrderPageHeader
orderNumber={order.orderNumber}
createdAt={order.createdAt}
status={order.status}
paymentStatus={order.paymentStatus}
/>
<OrderActionsBar
orderId={order._id}
status={order.status}
paymentStatus={order.paymentStatus}
trackingNumber={order.trackingNumber}
returnRequestedAt={order.returnRequestedAt}
returnReceivedAt={order.returnReceivedAt}
total={order.total}
currency={order.currency}
/>
{/* Two-column layout */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_340px]">
{/* ── Left column ──────────────────────────────────────────────────── */}
<div className="flex flex-col gap-6">
<OrderItemsCard items={order.items} currency={order.currency} />
<OrderFinancialsCard
subtotal={order.subtotal}
shipping={order.shipping}
discount={order.discount}
tax={order.tax}
total={order.total}
currency={order.currency}
/>
<OrderTimelineCard events={events} />
</div>
{/* ── Right column ─────────────────────────────────────────────────── */}
<div className="flex flex-col gap-6">
<CustomerCard
name={order.shippingAddressSnapshot.fullName}
email={order.email}
/>
<ShippingAddressCard address={order.shippingAddressSnapshot} />
<FulfilmentCard
carrier={order.carrier}
shippingMethod={order.shippingMethod}
shippingServiceCode={order.shippingServiceCode}
trackingNumber={order.trackingNumber}
trackingUrl={order.trackingUrl}
labelUrl={order.labelUrl}
returnLabelUrl={order.returnLabelUrl}
trackingStatus={order.trackingStatus}
estimatedDelivery={order.estimatedDelivery}
actualDelivery={order.actualDelivery}
status={order.status}
/>
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,267 @@
"use client"
import { useState, useMemo } from "react"
import { usePaginatedQuery } from "convex/react"
import { useRouter } from "next/navigation"
import { api } from "../../../../../../convex/_generated/api"
import { formatPrice } from "@repo/utils"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { HugeiconsIcon } from "@hugeicons/react"
import {
Search01Icon,
Cancel01Icon,
ArrowRight01Icon,
} from "@hugeicons/core-free-icons"
import { OrderStatusBadge } from "@/components/orders/shared/OrderStatusBadge"
import { OrderPaymentBadge } from "@/components/orders/shared/OrderPaymentBadge"
import {
ORDER_STATUS_CONFIG,
type OrderStatus,
} from "@/components/orders/shared/statusConfig"
// ─── Status filter options ────────────────────────────────────────────────────
const STATUS_OPTIONS = [
{ value: "all", label: "All statuses" },
...Object.entries(ORDER_STATUS_CONFIG).map(([value, config]) => ({
value,
label: config.label,
})),
]
// ─── Skeleton ─────────────────────────────────────────────────────────────────
function TableSkeleton() {
return (
<>
{Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-40" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16" />
</TableCell>
<TableCell>
<Skeleton className="size-4" />
</TableCell>
</TableRow>
))}
</>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function OrdersPage() {
const router = useRouter()
const [searchInput, setSearchInput] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const {
results,
status,
loadMore,
} = usePaginatedQuery(
api.orders.listAll,
statusFilter !== "all" ? { status: statusFilter } : {},
{ initialNumItems: 25 },
)
const isLoading = status === "LoadingFirstPage"
// Client-side search over loaded results.
// NOTE: filtered to the currently loaded page only — no server-side search
// index exists yet. Add api.orders.searchByOrderNumberOrEmail when needed.
const orders = useMemo(() => {
const q = searchInput.trim().toLowerCase()
if (!q) return results
return results.filter(
(o) =>
o.orderNumber.toLowerCase().includes(q) ||
o.email.toLowerCase().includes(q),
)
}, [results, searchInput])
const isSearching = searchInput.trim().length > 0
function emptyMessage() {
if (isSearching) return `No orders match "${searchInput.trim()}".`
if (statusFilter !== "all") {
const label =
ORDER_STATUS_CONFIG[statusFilter as OrderStatus]?.label ?? statusFilter
return `No orders with status "${label}".`
}
return "No orders yet."
}
return (
<main className="flex flex-1 flex-col gap-4 p-4 pt-0">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Orders</h1>
</div>
{/* Toolbar */}
<div className="flex items-center gap-2">
<div className="relative max-w-sm flex-1">
<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 by order # or email…"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-8 pr-8"
/>
{searchInput && (
<button
type="button"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchInput("")}
aria-label="Clear search"
>
<HugeiconsIcon
icon={Cancel01Icon}
strokeWidth={2}
className="size-4"
/>
</button>
)}
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger size="default" className="w-40">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
{STATUS_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Table */}
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead scope="col">Order #</TableHead>
<TableHead scope="col">Customer</TableHead>
<TableHead scope="col">Date</TableHead>
<TableHead scope="col">Status</TableHead>
<TableHead scope="col">Payment</TableHead>
<TableHead scope="col" className="text-right">
Total
</TableHead>
<TableHead scope="col" className="w-8" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton />
) : orders.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="py-16 text-center text-sm text-muted-foreground"
>
{emptyMessage()}
</TableCell>
</TableRow>
) : (
orders.map((order) => (
<TableRow
key={order._id}
className="cursor-pointer"
onClick={() => router.push(`/orders/${order._id}`)}
>
<TableCell className="font-mono text-xs">
{order.orderNumber}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{order.email}
</TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{new Date(order.createdAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
})}
</TableCell>
<TableCell>
<OrderStatusBadge status={order.status} />
</TableCell>
<TableCell>
<OrderPaymentBadge status={order.paymentStatus} />
</TableCell>
<TableCell className="text-right font-medium">
{formatPrice(order.total, order.currency.toUpperCase())}
</TableCell>
<TableCell>
<HugeiconsIcon
icon={ArrowRight01Icon}
strokeWidth={2}
className="size-4 text-muted-foreground"
aria-hidden="true"
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination footer — list mode only (not shown during client-side search) */}
{!isSearching && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{status === "Exhausted"
? `${results.length} order${results.length !== 1 ? "s" : ""} total`
: `${results.length} loaded`}
</span>
{status === "CanLoadMore" && (
<Button variant="outline" size="sm" onClick={() => loadMore(25)}>
Load more
</Button>
)}
</div>
)}
</main>
)
}

View File

@@ -0,0 +1,14 @@
export default function DashboardPage() {
return (
<main>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
<div className="aspect-video rounded-xl bg-muted/50" />
</div>
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min lg:min-h-[100vh]" />
</div>
</main>
);
}

View File

@@ -0,0 +1,204 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation } from "convex/react"
import { useRouter, useParams } from "next/navigation"
import Link from "next/link"
import { api } from "../../../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../../../convex/_generated/dataModel"
import { Button, buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
ProductForm,
buildProductPayload,
type ProductFormValues,
} from "@/components/products/ProductForm"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowLeft01Icon, Archive01Icon } from "@hugeicons/core-free-icons"
export default function EditProductPage() {
const params = useParams()
const productId = params.id as Id<"products">
const router = useRouter()
const product = useQuery(api.products.getByIdForAdmin, { id: productId })
const categories = useQuery(api.categories.list, {}) ?? []
const updateProduct = useMutation(api.products.update)
const archiveProduct = useMutation(api.products.archive)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [archiveOpen, setArchiveOpen] = useState(false)
const [isArchiving, setIsArchiving] = useState(false)
async function handleSubmit(values: ProductFormValues) {
setIsSubmitting(true)
setError(null)
try {
const payload = buildProductPayload(values)
await updateProduct({
id: productId,
...payload,
categoryId: payload.categoryId as Id<"categories">,
})
router.push("/products")
} catch (e: any) {
setError(e?.message ?? "Failed to save product. Please try again.")
setIsSubmitting(false)
}
}
async function handleArchive() {
setIsArchiving(true)
try {
await archiveProduct({ id: productId })
router.push("/products")
} catch (e: any) {
setError(e?.message ?? "Failed to archive product.")
setIsArchiving(false)
}
}
// Loading state
if (product === undefined) {
return (
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
<div className="flex flex-col gap-1">
<Skeleton className="h-7 w-20" />
<Skeleton className="h-7 w-48" />
</div>
<div className="max-w-2xl space-y-8">
<div className="space-y-4">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<div className="grid grid-cols-2 gap-4">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
</div>
</div>
</main>
)
}
// Not found
if (product === null) {
return (
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
<div className="flex flex-col gap-1">
<Link
href="/products"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "w-fit -ml-2")}
>
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} />
Products
</Link>
<h1 className="text-xl font-semibold">Product not found</h1>
</div>
<p className="text-sm text-muted-foreground">
This product does not exist or you do not have permission to view it.
</p>
</main>
)
}
const defaultValues: Partial<ProductFormValues> = {
name: product.name ?? "",
slug: product.slug ?? "",
status: product.status ?? "draft",
categoryId: product.categoryId ?? "",
tags: (product.tags ?? []).join(", "),
shortDescription: product.shortDescription ?? "",
description: product.description ?? "",
brand: product.brand ?? "",
petSize: (product.attributes?.petSize ?? []).join(", "),
ageRange: (product.attributes?.ageRange ?? []).join(", "),
specialDiet: (product.attributes?.specialDiet ?? []).join(", "),
material: product.attributes?.material ?? "",
flavor: product.attributes?.flavor ?? "",
seoTitle: product.seoTitle ?? "",
seoDescription: product.seoDescription ?? "",
canonicalSlug: product.canonicalSlug ?? "",
}
return (
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
<div className="flex items-start justify-between">
<div className="flex flex-col gap-1">
<Link
href="/products"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "w-fit -ml-2")}
>
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} />
Products
</Link>
<h1 className="text-xl font-semibold">{product.name}</h1>
</div>
{product.status !== "archived" && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setArchiveOpen(true)}
>
<HugeiconsIcon icon={Archive01Icon} strokeWidth={2} />
Archive
</Button>
)}
</div>
{error && (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="max-w-2xl">
<ProductForm
mode="edit"
categories={categories}
defaultValues={defaultValues}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
</div>
<AlertDialog open={archiveOpen} onOpenChange={setArchiveOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Archive &ldquo;{product.name}&rdquo;?
</AlertDialogTitle>
<AlertDialogDescription>
This product will no longer appear on the storefront. You can
restore it by editing the product status later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleArchive}
disabled={isArchiving}
>
{isArchiving ? "Archiving…" : "Archive"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</main>
)
}

View File

@@ -0,0 +1,71 @@
"use client"
import { useState } from "react"
import { useQuery, useMutation } from "convex/react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { api } from "../../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../../convex/_generated/dataModel"
import { buttonVariants } from "@/components/ui/button"
import {
ProductForm,
buildProductPayload,
type ProductFormValues,
} from "@/components/products/ProductForm"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons"
import { cn } from "@/lib/utils"
export default function NewProductPage() {
const router = useRouter()
const categories = useQuery(api.categories.list, {}) ?? []
const createProduct = useMutation(api.products.create)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(values: ProductFormValues) {
setIsSubmitting(true)
setError(null)
try {
const payload = buildProductPayload(values)
await createProduct({
...payload,
categoryId: payload.categoryId as Id<"categories">,
})
router.push("/products")
} catch (e: any) {
setError(e?.message ?? "Failed to create product. Please try again.")
setIsSubmitting(false)
}
}
return (
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
<div className="flex flex-col gap-1">
<Link
href="/products"
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "w-fit -ml-2")}
>
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} />
Products
</Link>
<h1 className="text-xl font-semibold">New product</h1>
</div>
{error && (
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<div className="max-w-2xl">
<ProductForm
mode="create"
categories={categories}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
</div>
</main>
)
}

View File

@@ -0,0 +1,471 @@
"use client"
import { useState, useEffect, useMemo } from "react"
import { usePaginatedQuery, useQuery } from "convex/react"
import { api } from "../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../convex/_generated/dataModel"
import Link from "next/link"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { HugeiconsIcon } from "@hugeicons/react"
import {
Search01Icon,
Cancel01Icon,
ArrowUpDownIcon,
SortByUp01Icon,
SortByDown01Icon,
AddCircleIcon,
} from "@hugeicons/core-free-icons"
import {
ProductPreviewDialog,
type PreviewProduct,
} from "@/components/products/ProductPreviewDialog"
import { ProductActionsMenu } from "@/components/products/ProductActionsMenu"
import { cn } from "@/lib/utils"
// ─── Types ────────────────────────────────────────────────────────────────────
type SortField = "name" | "brand" | "childCategory"
type SortOrder = "asc" | "desc"
type ColumnKey = "brand" | "childCategory" | "status" | "slug" | "tags" | "updated"
const OPTIONAL_COLUMNS: { key: ColumnKey; label: string }[] = [
{ key: "brand", label: "Brand" },
{ key: "childCategory", label: "Category" },
{ key: "status", label: "Status" },
{ key: "slug", label: "Slug" },
{ key: "tags", label: "Tags" },
{ key: "updated", label: "Updated" },
]
const DEFAULT_VISIBLE: Record<ColumnKey, boolean> = {
brand: true,
childCategory: true,
status: true,
slug: true,
tags: false,
updated: false,
}
const STATUS_CONFIG = {
active: { label: "Active", variant: "default" as const },
draft: { label: "Draft", variant: "secondary" as const },
archived: { label: "Archived", variant: "outline" as const },
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function SortableHeader({
field,
sortField,
sortOrder,
onSort,
children,
}: {
field: SortField
sortField: SortField | null
sortOrder: SortOrder
onSort: (field: SortField) => void
children: React.ReactNode
}) {
const active = sortField === field
return (
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={() => onSort(field)}
>
{children}
<HugeiconsIcon
icon={
active
? sortOrder === "asc"
? SortByUp01Icon
: SortByDown01Icon
: ArrowUpDownIcon
}
strokeWidth={2}
className={cn(
"size-3.5",
active ? "text-foreground" : "text-muted-foreground",
)}
/>
</button>
)
}
function TableSkeleton({
visibleCols,
}: {
visibleCols: Record<ColumnKey, boolean>
}) {
return (
<>
{Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-36" />
</TableCell>
{visibleCols.brand && (
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
)}
{visibleCols.childCategory && (
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
)}
{visibleCols.status && (
<TableCell>
<Skeleton className="h-5 w-16 rounded-full" />
</TableCell>
)}
{visibleCols.slug && (
<TableCell>
<Skeleton className="h-4 w-32" />
</TableCell>
)}
{visibleCols.tags && (
<TableCell>
<Skeleton className="h-4 w-20" />
</TableCell>
)}
{visibleCols.updated && (
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
)}
<TableCell>
<Skeleton className="size-7 rounded-lg" />
</TableCell>
</TableRow>
))}
</>
)
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ProductsPage() {
const [searchInput, setSearchInput] = useState("")
const [searchQuery, setSearchQuery] = useState("")
const [visibleCols, setVisibleCols] =
useState<Record<ColumnKey, boolean>>(DEFAULT_VISIBLE)
const [sortField, setSortField] = useState<SortField | null>(null)
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
const [previewProduct, setPreviewProduct] = useState<PreviewProduct | null>(null)
const [previewOpen, setPreviewOpen] = useState(false)
// Debounce search input by 300ms
useEffect(() => {
const t = setTimeout(() => setSearchQuery(searchInput), 300)
return () => clearTimeout(t)
}, [searchInput])
const isSearching = searchQuery.trim().length > 0
const {
results: listResults,
status: listStatus,
loadMore,
} = usePaginatedQuery(api.products.list, {}, { initialNumItems: 25 })
const searchResults = useQuery(
api.products.search,
isSearching ? { query: searchQuery, limit: 100 } : "skip",
)
const isLoading = isSearching
? searchResults === undefined
: listStatus === "LoadingFirstPage"
const rawProducts = isSearching ? (searchResults ?? []) : listResults
const products = useMemo(() => {
if (!sortField) return rawProducts
return [...rawProducts].sort((a: any, b: any) => {
const aVal: string =
sortField === "name"
? (a.name ?? "")
: sortField === "brand"
? (a.brand ?? "")
: (a.childCategorySlug ?? "")
const bVal: string =
sortField === "name"
? (b.name ?? "")
: sortField === "brand"
? (b.brand ?? "")
: (b.childCategorySlug ?? "")
return sortOrder === "asc"
? aVal.localeCompare(bVal)
: bVal.localeCompare(aVal)
})
}, [rawProducts, sortField, sortOrder])
function handleSort(field: SortField) {
if (sortField === field) {
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
} else {
setSortField(field)
setSortOrder("asc")
}
}
function toggleColumn(key: ColumnKey, checked: boolean) {
setVisibleCols((prev) => ({ ...prev, [key]: checked }))
}
function openPreview(product: any) {
setPreviewProduct(product as PreviewProduct)
setPreviewOpen(true)
}
const visibleOptionalCount = Object.values(visibleCols).filter(Boolean).length
// Name + visible optional cols + Actions
const totalColSpan = 1 + visibleOptionalCount + 1
return (
<main className="flex flex-1 flex-col gap-4 p-4 pt-0">
{/* Header */}
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Products</h1>
<Link href="/products/new" className={buttonVariants()}>
<HugeiconsIcon icon={AddCircleIcon} strokeWidth={2} />
New product
</Link>
</div>
{/* Toolbar */}
<div className="flex items-center gap-2">
<div className="relative max-w-sm flex-1">
<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={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
className="pl-8 pr-8"
/>
{searchInput && (
<button
type="button"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchInput("")}
aria-label="Clear search"
>
<HugeiconsIcon
icon={Cancel01Icon}
strokeWidth={2}
className="size-4"
/>
</button>
)}
</div>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" size="sm" />}>
Columns
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{OPTIONAL_COLUMNS.map(({ key, label }) => (
<DropdownMenuCheckboxItem
key={key}
checked={visibleCols[key]}
onCheckedChange={(checked: boolean) =>
toggleColumn(key, checked)
}
>
{label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Table */}
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead scope="col">
<SortableHeader
field="name"
sortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
>
Name
</SortableHeader>
</TableHead>
{visibleCols.brand && (
<TableHead scope="col">
<SortableHeader
field="brand"
sortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
>
Brand
</SortableHeader>
</TableHead>
)}
{visibleCols.childCategory && (
<TableHead scope="col">
<SortableHeader
field="childCategory"
sortField={sortField}
sortOrder={sortOrder}
onSort={handleSort}
>
Category
</SortableHeader>
</TableHead>
)}
{visibleCols.status && (
<TableHead scope="col">Status</TableHead>
)}
{visibleCols.slug && <TableHead scope="col">Slug</TableHead>}
{visibleCols.tags && <TableHead scope="col">Tags</TableHead>}
{visibleCols.updated && (
<TableHead scope="col">Updated</TableHead>
)}
<TableHead scope="col" className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableSkeleton visibleCols={visibleCols} />
) : products.length === 0 ? (
<TableRow>
<TableCell
colSpan={totalColSpan}
className="py-16 text-center text-sm text-muted-foreground"
>
{isSearching
? `No products match "${searchQuery}".`
: "No products yet. Create your first product to get started."}
</TableCell>
</TableRow>
) : (
products.map((product: any) => {
const statusCfg =
STATUS_CONFIG[product.status as keyof typeof STATUS_CONFIG]
return (
<TableRow key={product._id}>
<TableCell>
<button
type="button"
className="font-medium hover:underline text-left"
onClick={() => openPreview(product)}
>
{product.name}
</button>
</TableCell>
{visibleCols.brand && (
<TableCell className="text-muted-foreground">
{product.brand ?? "—"}
</TableCell>
)}
{visibleCols.childCategory && (
<TableCell className="text-muted-foreground">
{product.childCategorySlug ?? "—"}
</TableCell>
)}
{visibleCols.status && (
<TableCell>
{statusCfg ? (
<Badge variant={statusCfg.variant}>
{statusCfg.label}
</Badge>
) : (
<span className="text-muted-foreground">
{product.status}
</span>
)}
</TableCell>
)}
{visibleCols.slug && (
<TableCell className="font-mono text-xs text-muted-foreground">
{product.slug}
</TableCell>
)}
{visibleCols.tags && (
<TableCell className="text-muted-foreground">
{product.tags?.length > 0
? product.tags.join(", ")
: "—"}
</TableCell>
)}
{visibleCols.updated && (
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
{product.updatedAt
? new Date(product.updatedAt).toLocaleDateString()
: "—"}
</TableCell>
)}
<TableCell>
<ProductActionsMenu
productId={product._id as Id<"products">}
productName={product.name}
isArchived={product.status === "archived"}
/>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
{/* Pagination footer — list mode only */}
{!isSearching && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{listStatus === "Exhausted"
? `${listResults.length} product${listResults.length !== 1 ? "s" : ""} total`
: `${listResults.length} loaded`}
</span>
{listStatus === "CanLoadMore" && (
<Button variant="outline" size="sm" onClick={() => loadMore(25)}>
Load more
</Button>
)}
</div>
)}
<ProductPreviewDialog
product={previewProduct}
open={previewOpen}
onOpenChange={setPreviewOpen}
/>
</main>
)
}

View File

@@ -0,0 +1,144 @@
"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/shared/ProductSearchSection"
import { VariantsTable, VariantsTableSkeleton, type Variant } from "../../../components/variants/VariantsTable"
import { VariantPreviewDialog } from "../../../components/variants/VariantPreviewDialog"
import { CreateVariantDialog } from "../../../components/variants/CreateVariantDialog"
import { EditVariantDialog } from "../../../components/variants/EditVariantDialog"
import { Separator } from "../../../components/ui/separator"
import { Button } from "../../../components/ui/button"
import {
Table,
TableBody,
} from "../../../components/ui/table"
import { HugeiconsIcon } from "@hugeicons/react"
import { AddCircleIcon } from "@hugeicons/core-free-icons"
interface SelectedProduct {
_id: string
name: string
}
export default function VariantsPage() {
const [selectedProduct, setSelectedProduct] = useState<SelectedProduct | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [editVariant, setEditVariant] = useState<Variant | null>(null)
const [previewVariant, setPreviewVariant] = useState<Variant | null>(null)
const productData = useQuery(
api.products.getByIdForAdmin,
selectedProduct ? { id: selectedProduct._id as Id<"products"> } : "skip",
)
function handleProductSelect(product: SelectedProduct) {
setSelectedProduct(product)
}
function handleSearchClear() {
setSelectedProduct(null)
}
const variants = (productData?.variants ?? []) as Variant[]
const isLoading = selectedProduct !== null && productData === undefined
return (
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
<h1 className="text-xl font-semibold">Variants</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>
{/* No product selected */}
{!selectedProduct && (
<p className="text-sm text-muted-foreground">
Search and select a product to manage its variants.
</p>
)}
{/* Product selected */}
{selectedProduct && (
<>
<Separator />
<section className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold">{selectedProduct.name}</h2>
<p className="text-sm text-muted-foreground">
{isLoading
? "Loading variants…"
: `${variants.length} variant${variants.length !== 1 ? "s" : ""}`}
</p>
</div>
{!isLoading && (
<Button onClick={() => setCreateOpen(true)}>
<HugeiconsIcon icon={AddCircleIcon} strokeWidth={2} />
Create variant
</Button>
)}
</div>
{/* Loading skeleton */}
{isLoading && (
<div className="rounded-lg border">
<Table>
<TableBody>
<VariantsTableSkeleton />
</TableBody>
</Table>
</div>
)}
{/* Variants table */}
{!isLoading && productData && (
<VariantsTable
variants={variants}
onPreview={(v) => setPreviewVariant(v)}
onEdit={(v) => setEditVariant(v)}
/>
)}
</section>
</>
)}
{/* Preview dialog */}
<VariantPreviewDialog
variant={previewVariant}
open={previewVariant !== null}
onOpenChange={(open) => { if (!open) setPreviewVariant(null) }}
/>
{/* Create dialog */}
{selectedProduct && (
<CreateVariantDialog
productId={selectedProduct._id as Id<"products">}
productName={selectedProduct.name}
brand={productData?.brand}
open={createOpen}
onOpenChange={setCreateOpen}
/>
)}
{/* Edit dialog */}
<EditVariantDialog
variant={editVariant}
productName={selectedProduct?.name ?? ""}
brand={productData?.brand}
open={editVariant !== null}
onOpenChange={(open) => { if (!open) setEditVariant(null) }}
/>
</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,126 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.60 0.10 185);
--primary-foreground: oklch(0.98 0.01 181);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.58 0.22 27);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.85 0.13 181);
--chart-2: oklch(0.78 0.13 182);
--chart-3: oklch(0.70 0.12 183);
--chart-4: oklch(0.60 0.10 185);
--chart-5: oklch(0.51 0.09 186);
--radius: 0.45rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.60 0.10 185);
--sidebar-primary-foreground: oklch(0.98 0.01 181);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.70 0.12 183);
--primary-foreground: oklch(0.28 0.04 193);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.85 0.13 181);
--chart-2: oklch(0.78 0.13 182);
--chart-3: oklch(0.70 0.12 183);
--chart-4: oklch(0.60 0.10 185);
--chart-5: oklch(0.51 0.09 186);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.78 0.13 182);
--sidebar-primary-foreground: oklch(0.28 0.04 193);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { DM_Sans, Geist, Geist_Mono } from "next/font/google";
import { ClerkProvider } from "@clerk/nextjs";
import { ConvexClientProvider } from "@repo/convex";
import { Toaster } from "sonner";
import "./globals.css";
const dmSans = DM_Sans({subsets:['latin'],variable:'--font-sans'});
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: {
template: "%s | Admin",
default: "Admin Dashboard",
},
description: "Store administration",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={ dmSans.className}>
<ClerkProvider>
<ConvexClientProvider>
{children}
<Toaster richColors position="bottom-right" />
</ConvexClientProvider>
</ClerkProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,48 @@
import Image from "next/image";
import Link from "next/link";
export default function NotFound() {
return (
<main className="flex flex-1 items-center justify-center bg-[var(--background)] px-4 py-16 md:px-6 md:py-24">
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-8 md:flex-row md:gap-12">
<div className="flex w-full flex-col items-center text-center md:w-1/2 md:items-start md:text-left">
<h2 className="text-2xl font-black tracking-tight md:text-3xl flex flex-wrap gap-x-3 gap-y-0 items-baseline">
<span className="font-extralight">Page</span>
<span>not found</span>
</h2>
<p className="mt-4 max-w-md text-base leading-relaxed md:text-lg">
Sorry, we couldn&apos;t find the page you&apos;re looking for. It
may have been moved, renamed, or no longer exists.
</p>
<Link
href="/"
className="mt-8 inline-flex items-center rounded-full bg-primary px-8 py-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 md:w-auto"
>
Back to Home
</Link>
</div>
<div className="order-first flex w-full max-w-xs flex-col items-center gap-2 md:order-last md:w-1/2 md:max-w-none">
<Image
src="/illustrations/404_not_found.svg"
alt="Page not found illustration"
width={500}
height={500}
priority
className="h-auto w-full"
/>
<a
href="https://storyset.com/online"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-[var(--muted)] hover:underline"
>
Online illustrations by Storyset
</a>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,33 @@
"use client";
interface AccessDeniedProps {
reason: "not_authenticated" | "not_admin" | "no_user_record";
onSignOut: () => void;
}
export function AccessDenied({ onSignOut }: AccessDeniedProps) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex flex-col items-center gap-4 text-center">
<h1 className="text-xl font-semibold text-gray-900">Access Denied</h1>
<p className="text-sm text-gray-500">
You don&apos;t have permission to access the admin dashboard.
</p>
<div className="flex gap-3">
<button
onClick={onSignOut}
className="rounded-md bg-gray-900 px-4 py-2 text-sm text-white hover:bg-gray-700"
>
Sign out
</button>
<a
href={process.env.NEXT_PUBLIC_STOREFRONT_URL ?? "/"}
className="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Go to storefront
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
"use client";
import { useAdminAuth } from "../../hooks/useAdminAuth";
import { useClerk } from "@clerk/nextjs";
import { LoadingSkeleton } from "./LoadingSkeleton";
import { AccessDenied } from "./AccessDenied";
export function AdminAuthGate({ children }: { children: React.ReactNode }) {
const auth = useAdminAuth();
const { signOut } = useClerk();
if (auth.status === "loading") {
return <LoadingSkeleton />;
}
if (auth.status === "denied") {
return <AccessDenied reason={auth.reason} onSignOut={() => signOut()} />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,9 @@
"use client";
import { useStoreUserEffect } from "../../hooks/useStoreUserEffect";
export function AdminUserSync() {
useStoreUserEffect();
return null;
}

View File

@@ -0,0 +1,12 @@
"use client";
export function LoadingSkeleton() {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-gray-600" />
<p className="text-sm text-gray-500">Verifying access...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
"use client";
import { useAdminAuth } from "../../hooks/useAdminAuth";
interface RequireRoleProps {
role: "super_admin";
fallback?: React.ReactNode;
children: React.ReactNode;
}
export function RequireRole({ role, fallback = null, children }: RequireRoleProps) {
const auth = useAdminAuth();
if (auth.status !== "authorized") return null;
if (auth.role !== role) return <>{fallback}</>;
return <>{children}</>;
}

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,67 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { HugeiconsIcon } from "@hugeicons/react";
import { Home01Icon } from "@hugeicons/core-free-icons";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { ROUTE_LABELS } from "@/lib/constants/app.constants";
export function DynamicBreadcrumb() {
const pathname = usePathname();
const segments = pathname.split("/").filter(Boolean);
// Root path — render "Dashboard" as a single page crumb
if (segments.length === 0) {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>Dashboard</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}
return (
<Breadcrumb>
<BreadcrumbList>
{/* Home icon link */}
<BreadcrumbItem>
<BreadcrumbLink render={<Link href="/" />}>
<HugeiconsIcon icon={Home01Icon} size={16} />
</BreadcrumbLink>
</BreadcrumbItem>
{segments.map((segment, index) => {
const href = "/" + segments.slice(0, index + 1).join("/");
const label = ROUTE_LABELS[segment] ?? segment;
const isLast = index === segments.length - 1;
return (
<span key={href} className="contents">
<BreadcrumbSeparator />
<BreadcrumbItem>
{isLast ? (
<BreadcrumbPage>{label}</BreadcrumbPage>
) : (
<BreadcrumbLink render={<Link href={href} />}>
{label}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</span>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Store01Icon } from "@hugeicons/core-free-icons";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenu,
SidebarRail,
} from "@/components/ui/sidebar";
import { NavMain } from "./nav-main";
import { NavUser } from "./nav-user";
import { NAV_LINKS } from "@/lib/constants/app.constants";
import { PawPrint } from "lucide-react";
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
return (
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" tooltip="The Pet Loft Admin">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<PawPrint className="size-4" />
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">The Pet Loft</span>
<span className="truncate text-xs text-muted-foreground">Admin</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
{/* dashboard */}
<NavMain overview={NAV_LINKS.overview} navMain={[]} isOverview={true} />
{/* Application */}
<NavMain navMain={NAV_LINKS.navMain} overview={[]} isOverview={false} />
{/* Users */}
<NavMain overview={NAV_LINKS.users} navMain={[]} isOverview={true} />
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
}

View File

@@ -0,0 +1,113 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import { ArrowRight01Icon } from "@hugeicons/core-free-icons";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
import { CollapsibleTrigger, CollapsibleContent, Collapsible } from "@/components/ui/collapsible";
type NavItem = {
title: string;
url: string;
icon: IconSvgElement;
items?: { title: string; url: string }[];
};
function NavMainItem({ item, pathname }: { item: NavItem; pathname: string }) {
const isGroupActive = pathname.startsWith(item.url);
const [open, setOpen] = useState(isGroupActive);
// Auto-open when navigating into this group's routes
useEffect(() => {
if (isGroupActive) setOpen(true);
}, [isGroupActive]);
return (
<SidebarMenuItem>
<Collapsible open={open} onOpenChange={setOpen} className="group/collapsible">
<CollapsibleTrigger
render={
<SidebarMenuButton tooltip={item.title} isActive={isGroupActive} />
}
>
<HugeiconsIcon icon={item.icon} size={16} />
<span>{item.title}</span>
<HugeiconsIcon
icon={ArrowRight01Icon}
className="ml-auto transition-transform duration-200 group-data-[open]/collapsible:rotate-90"
/>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
render={<Link href={subItem.url} />}
isActive={pathname === subItem.url}
>
<span>{subItem.title}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</Collapsible>
</SidebarMenuItem>
);
}
export function NavMain({
overview,
isOverview,
navMain,
}: {
overview: Omit<NavItem, "items">[];
isOverview?: boolean;
navMain: NavItem[];
}) {
const pathname = usePathname();
if (isOverview) {
return (
<SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu>
{overview.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
render={<Link href={item.url} />}
tooltip={item.title}
isActive={pathname === item.url}
>
<HugeiconsIcon icon={item.icon} size={16} />
<span>{item.title}</span>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
);
}
return (
<SidebarGroup>
<SidebarGroupLabel>Application</SidebarGroupLabel>
<SidebarMenu>
{navMain.map((item) => (
<NavMainItem key={item.title} item={item} pathname={pathname} />
))}
</SidebarMenu>
</SidebarGroup>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { useUser } from "@clerk/nextjs";
import { UserButton } from "@clerk/nextjs";
import { useSidebar } from "@/components/ui/sidebar";
export function NavUser() {
const { user } = useUser();
const { state } = useSidebar();
const isCollapsed = state === "collapsed";
return (
<div className="flex items-center gap-2 px-2 py-1.5">
<UserButton afterSignOutUrl="/sign-in" />
{!isCollapsed && (
<div className="grid flex-1 text-left text-sm leading-tight overflow-hidden">
<span className="truncate font-medium">{user?.fullName}</span>
<span className="truncate text-xs text-muted-foreground">
{user?.primaryEmailAddress?.emailAddress}
</span>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
"use client"
import { useState } from "react"
import { useAction } from "convex/react"
import { toast } from "sonner"
import { api } from "../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../convex/_generated/dataModel"
import { Button } from "@/components/ui/button"
interface Props {
orderId: Id<"orders">
}
export function AcceptReturnButton({ orderId }: Props) {
const [isLoading, setIsLoading] = useState(false)
const acceptReturn = useAction(api.returnActions.acceptReturn)
async function handleClick() {
setIsLoading(true)
try {
const result = await acceptReturn({ orderId })
if (result.success) {
toast.success(`Return accepted. Tracking: ${result.returnTrackingNumber}`)
} else {
toast.error((result as { success: false; code: string; message: string }).message)
}
} catch (e: any) {
toast.error(e?.message ?? "Failed to accept return.")
} finally {
setIsLoading(false)
}
}
return (
<Button onClick={handleClick} disabled={isLoading} variant="outline">
{isLoading && (
<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>
)}
{isLoading ? "Accepting…" : "Accept Return"}
</Button>
)
}

View File

@@ -0,0 +1,62 @@
"use client"
import { useState } from "react"
import { useAction } from "convex/react"
import { toast } from "sonner"
import { api } from "../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../convex/_generated/dataModel"
import { Button } from "@/components/ui/button"
interface Props {
orderId: Id<"orders">
}
export function CreateLabelButton({ orderId }: Props) {
const [isLoading, setIsLoading] = useState(false)
const createLabel = useAction(api.fulfillmentActions.createShippingLabel)
async function handleClick() {
setIsLoading(true)
try {
const result = await createLabel({ orderId })
if (result.success) {
toast.success(`Label created. Tracking: ${result.trackingNumber}`)
} else {
toast.error((result as { success: false; code: string; message: string }).message)
}
} catch (e: any) {
toast.error(e?.message ?? "Failed to create shipping label.")
} finally {
setIsLoading(false)
}
}
return (
<Button onClick={handleClick} disabled={isLoading}>
{isLoading && (
<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>
)}
{isLoading ? "Creating…" : "Create Label"}
</Button>
)
}

View File

@@ -0,0 +1,99 @@
"use client"
import { useState } from "react"
import { useAction } from "convex/react"
import { toast } from "sonner"
import { formatPrice } from "@repo/utils"
import { api } from "../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../convex/_generated/dataModel"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
interface Props {
orderId: Id<"orders">
total: number
currency: string
}
export function IssueRefundButton({ orderId, total, currency }: Props) {
const [open, setOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const issueRefund = useAction(api.returnActions.issueRefund)
const formattedTotal = formatPrice(total, currency.toUpperCase())
async function handleConfirm() {
setIsLoading(true)
try {
await issueRefund({ orderId })
toast.success("Refund issued.")
setOpen(false)
} catch (e: any) {
toast.error(e?.message ?? "Failed to issue refund.")
} finally {
setIsLoading(false)
}
}
return (
<>
<Button variant="destructive" onClick={() => setOpen(true)}>
Issue Refund
</Button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Issue full refund?</AlertDialogTitle>
<AlertDialogDescription>
A full refund of {formattedTotal} will be sent to the customer via
Stripe. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleConfirm}
disabled={isLoading}
>
{isLoading && (
<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>
)}
{isLoading ? "Refunding…" : "Issue Refund"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,90 @@
"use client"
import { useState } from "react"
import { useMutation } from "convex/react"
import { toast } from "sonner"
import { api } from "../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../convex/_generated/dataModel"
import { Button } from "@/components/ui/button"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
interface Props {
orderId: Id<"orders">
}
export function MarkReturnReceivedButton({ orderId }: Props) {
const [open, setOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const markReceived = useMutation(api.orders.markReturnReceived)
async function handleConfirm() {
setIsLoading(true)
try {
await markReceived({ id: orderId })
toast.success("Return marked as received.")
setOpen(false)
} catch (e: any) {
toast.error(e?.message ?? "Failed to mark return as received.")
} finally {
setIsLoading(false)
}
}
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
Mark Return Received
</Button>
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Mark return as received?</AlertDialogTitle>
<AlertDialogDescription>
Confirm that the returned items have arrived. You can then issue a
refund.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm} disabled={isLoading}>
{isLoading && (
<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>
)}
{isLoading ? "Saving…" : "Confirm"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,139 @@
"use client"
import { useState } from "react"
import { useMutation } from "convex/react"
import { toast } from "sonner"
import { api } from "../../../../../../convex/_generated/api"
import type { Id } from "../../../../../../convex/_generated/dataModel"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
ORDER_STATUS_CONFIG,
type OrderStatus,
} from "../shared/statusConfig"
interface Props {
orderId: Id<"orders">
currentStatus: string
}
const ALL_STATUSES = Object.keys(ORDER_STATUS_CONFIG) as OrderStatus[]
export function UpdateStatusDialog({ orderId, currentStatus }: Props) {
const [open, setOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const availableStatuses = ALL_STATUSES.filter((s) => s !== currentStatus)
const [selectedStatus, setSelectedStatus] = useState<string>(
availableStatuses[0] ?? "",
)
const updateStatus = useMutation(api.orders.updateStatus)
// Reset selection to first available when dialog opens
function handleOpenChange(next: boolean) {
if (next) {
setSelectedStatus(availableStatuses[0] ?? "")
}
setOpen(next)
}
async function handleSubmit() {
if (!selectedStatus) return
setIsSubmitting(true)
try {
await updateStatus({
id: orderId,
status: selectedStatus as OrderStatus,
})
const label = ORDER_STATUS_CONFIG[selectedStatus as OrderStatus]?.label
toast.success(`Status updated to ${label ?? selectedStatus}`)
setOpen(false)
} catch (e: any) {
toast.error(e?.message ?? "Failed to update status.")
} finally {
setIsSubmitting(false)
}
}
return (
<>
<Button variant="outline" onClick={() => handleOpenChange(true)}>
Update Status
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Update order status</DialogTitle>
</DialogHeader>
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a status" />
</SelectTrigger>
<SelectContent>
{availableStatuses.map((s) => (
<SelectItem key={s} value={s}>
{ORDER_STATUS_CONFIG[s].label}
</SelectItem>
))}
</SelectContent>
</Select>
<DialogFooter>
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!selectedStatus || isSubmitting}
>
{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 ? "Updating…" : "Update"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,25 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface Props {
name: string
email: string
}
export function CustomerCard({ name, email }: Props) {
return (
<Card>
<CardHeader>
<CardTitle>Customer</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-1">
<p className="text-sm font-medium">{name}</p>
<a
href={`mailto:${email}`}
className="text-sm text-muted-foreground hover:underline"
>
{email}
</a>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,157 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { HugeiconsIcon } from "@hugeicons/react"
import { ExternalLink } from "@hugeicons/core-free-icons"
interface Props {
carrier: string
shippingMethod: string
shippingServiceCode: string
trackingNumber?: string
trackingUrl?: string
labelUrl?: string
returnLabelUrl?: string
trackingStatus?: string
estimatedDelivery?: number
actualDelivery?: number
status: string
}
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-4 text-sm">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="text-right">{children}</span>
</div>
)
}
function ExternalLinkIcon() {
return (
<HugeiconsIcon
icon={ExternalLink}
strokeWidth={2}
className="inline size-3 shrink-0"
aria-hidden="true"
/>
)
}
export function FulfilmentCard({
carrier,
shippingMethod,
shippingServiceCode,
trackingNumber,
trackingUrl,
labelUrl,
returnLabelUrl,
trackingStatus,
estimatedDelivery,
actualDelivery,
status,
}: Props) {
const isDelivered = status === "delivered" || !!actualDelivery
return (
<Card>
<CardHeader>
<CardTitle>Fulfilment</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{/* ── State A & B: carrier info always shown ─────────────────────── */}
{carrier && <InfoRow label="Carrier">{carrier}</InfoRow>}
{shippingMethod && (
<InfoRow label="Service">{shippingMethod}</InfoRow>
)}
{shippingServiceCode && !isDelivered && !trackingNumber && (
<InfoRow label="Method">{shippingServiceCode}</InfoRow>
)}
{/* ── State A: no label yet ──────────────────────────────────────── */}
{!trackingNumber && (
<p className="mt-1 text-sm text-muted-foreground">
No label created yet.
</p>
)}
{/* ── State B & C: tracking info ─────────────────────────────────── */}
{trackingNumber && (
<>
<InfoRow label="Tracking">
<span className="font-mono text-xs">{trackingNumber}</span>
{trackingUrl && (
<a
href={trackingUrl}
target="_blank"
rel="noopener noreferrer"
className="ml-1.5 inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:underline"
>
Track <ExternalLinkIcon />
</a>
)}
</InfoRow>
{trackingStatus && (
<InfoRow label="Status">
<span className="font-mono text-xs uppercase">
{trackingStatus}
</span>
</InfoRow>
)}
{/* State B: estimated delivery */}
{!isDelivered && estimatedDelivery && (
<InfoRow label="Est. delivery">
{new Date(estimatedDelivery).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
})}
</InfoRow>
)}
{/* State C: actual delivery */}
{isDelivered && actualDelivery && (
<InfoRow label="Delivered">
{new Date(actualDelivery).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</InfoRow>
)}
{/* Label download — State B only */}
{!isDelivered && labelUrl && (
<InfoRow label={returnLabelUrl ? "Outbound label" : "Label"}>
<a
href={labelUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:underline"
>
Download <ExternalLinkIcon />
</a>
</InfoRow>
)}
{/* Return label — shown when return has been accepted */}
{returnLabelUrl && (
<InfoRow label="Return label">
<a
href={returnLabelUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:underline"
>
Download <ExternalLinkIcon />
</a>
</InfoRow>
)}
</>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,75 @@
import type { Id } from "../../../../../../convex/_generated/dataModel"
import { CreateLabelButton } from "../actions/CreateLabelButton"
import { AcceptReturnButton } from "../actions/AcceptReturnButton"
import { MarkReturnReceivedButton } from "../actions/MarkReturnReceivedButton"
import { IssueRefundButton } from "../actions/IssueRefundButton"
interface Props {
orderId: Id<"orders">
status: string
paymentStatus: string
trackingNumber?: string
returnRequestedAt?: number
returnReceivedAt?: number
total: number
currency: string
}
export function OrderActionsBar({
orderId,
status,
paymentStatus,
returnRequestedAt,
returnReceivedAt,
total,
currency,
}: Props) {
// confirmed + no return → create outbound label
if (status === "confirmed" && !returnRequestedAt) {
return (
<div className="flex flex-wrap items-center gap-2">
<CreateLabelButton orderId={orderId} />
</div>
)
}
// cancelled → refund if not already refunded
if (status === "cancelled" && paymentStatus !== "refunded") {
return (
<div className="flex flex-wrap items-center gap-2">
<IssueRefundButton orderId={orderId} total={total} currency={currency} />
</div>
)
}
// delivered + return requested → accept return (create return label)
if (status === "delivered" && returnRequestedAt) {
return (
<div className="flex flex-wrap items-center gap-2">
<AcceptReturnButton orderId={orderId} />
</div>
)
}
// processing + return in transit (accepted but not received) → mark received
if (status === "processing" && returnRequestedAt && !returnReceivedAt) {
return (
<div className="flex flex-wrap items-center gap-2">
<MarkReturnReceivedButton orderId={orderId} />
</div>
)
}
// completed → refund if not already refunded
if (status === "completed" && paymentStatus !== "refunded") {
return (
<div className="flex flex-wrap items-center gap-2">
<IssueRefundButton orderId={orderId} total={total} currency={currency} />
</div>
)
}
// All other states (pending, processing w/o return, shipped, delivered w/o return,
// refunded, return status) → no actions
return null
}

View File

@@ -0,0 +1,61 @@
import { formatPrice } from "@repo/utils"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
interface Props {
subtotal: number
shipping: number
discount: number
tax: number
total: number
currency: string
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span>{value}</span>
</div>
)
}
export function OrderFinancialsCard({
subtotal,
shipping,
discount,
tax,
total,
currency,
}: Props) {
const curr = currency.toUpperCase()
return (
<Card>
<CardHeader>
<CardTitle>Financials</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
<InfoRow label="Subtotal" value={formatPrice(subtotal, curr)} />
<InfoRow label="Shipping" value={formatPrice(shipping, curr)} />
{discount > 0 && (
<InfoRow
label="Discount"
value={`-${formatPrice(discount, curr)}`}
/>
)}
{tax > 0 && <InfoRow label="Tax" value={formatPrice(tax, curr)} />}
<Separator />
<div className="flex items-center justify-between text-sm font-semibold">
<span>Total</span>
<span>{formatPrice(total, curr)}</span>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,85 @@
import { formatPrice } from "@repo/utils"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
interface OrderItem {
_id: string
productName: string
variantName: string
sku: string
quantity: number
unitPrice: number
totalPrice: number
}
interface Props {
items: OrderItem[]
currency: string
}
export function OrderItemsCard({ items, currency }: Props) {
const curr = currency.toUpperCase()
return (
<Card>
<CardHeader>
<CardTitle>Order items</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="border-t">
<Table>
<TableHeader>
<TableRow>
<TableHead scope="col">Product</TableHead>
<TableHead scope="col">SKU</TableHead>
<TableHead scope="col" className="text-right">
Qty
</TableHead>
<TableHead scope="col" className="text-right">
Unit
</TableHead>
<TableHead scope="col" className="text-right">
Total
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item._id}>
<TableCell>
<div className="font-medium">{item.productName}</div>
<div className="text-xs text-muted-foreground">
{item.variantName}
</div>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{item.sku}
</TableCell>
<TableCell className="text-right">{item.quantity}</TableCell>
<TableCell className="text-right">
{formatPrice(item.unitPrice, curr)}
</TableCell>
<TableCell className="text-right font-medium">
{formatPrice(item.totalPrice, curr)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,50 @@
import Link from "next/link"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons"
import { OrderStatusBadge } from "../shared/OrderStatusBadge"
import { OrderPaymentBadge } from "../shared/OrderPaymentBadge"
interface Props {
orderNumber: string
createdAt: number
status: string
paymentStatus: string
}
export function OrderPageHeader({
orderNumber,
createdAt,
status,
paymentStatus,
}: Props) {
const formattedDate = new Date(createdAt).toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
return (
<div className="flex flex-col gap-1">
<Link
href="/orders"
className={cn(
buttonVariants({ variant: "ghost", size: "sm" }),
"-ml-2 w-fit",
)}
>
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} />
Orders
</Link>
<div className="flex flex-wrap items-center gap-2">
<h1 className="font-mono text-xl font-semibold">{orderNumber}</h1>
<OrderStatusBadge status={status} />
<OrderPaymentBadge status={paymentStatus} />
<span className="text-sm text-muted-foreground">{formattedDate}</span>
</div>
</div>
)
}

View File

@@ -0,0 +1,177 @@
import { cn } from "@/lib/utils"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"
import {
EVENT_TYPE_LABELS,
TIMELINE_DOT_COLOR,
type OrderEventType,
type OrderStatus,
} from "../shared/statusConfig"
// ─── Types ────────────────────────────────────────────────────────────────────
interface TimelineEvent {
_id: string
eventType: string
source: string
fromStatus?: string
toStatus?: string
payload?: string
createdAt: number
}
interface Props {
events: TimelineEvent[] | undefined
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function parsePayload(raw: string | undefined): Record<string, string> {
if (!raw) return {}
try {
return JSON.parse(raw)
} catch {
return {}
}
}
function getEventLabel(event: TimelineEvent): string {
switch (event.eventType as OrderEventType) {
case "status_change": {
const from = event.fromStatus ?? "—"
const to = event.toStatus ?? "—"
return `Status changed: ${from}${to}`
}
case "label_created": {
const p = parsePayload(event.payload)
const suffix = p.trackingNumber ? ` · ${p.trackingNumber}` : ""
return `${EVENT_TYPE_LABELS.label_created}${suffix}`
}
case "tracking_update": {
const p = parsePayload(event.payload)
const s = p.status ?? event.toStatus ?? ""
return s
? `${EVENT_TYPE_LABELS.tracking_update}: ${s}`
: EVENT_TYPE_LABELS.tracking_update
}
default: {
const label = EVENT_TYPE_LABELS[event.eventType as OrderEventType]
return label ?? event.eventType
}
}
}
function getDotColor(event: TimelineEvent): string {
if (event.eventType === "status_change" && event.toStatus) {
return (
TIMELINE_DOT_COLOR[event.toStatus as OrderStatus] ??
"text-muted-foreground"
)
}
return "text-muted-foreground"
}
const SOURCE_LABELS: Record<string, string> = {
admin: "by admin",
stripe_webhook: "via payment",
customer_cancel: "by customer",
customer_return: "by customer",
shippo_webhook: "via courier",
fulfillment: "by system",
}
function formatEventDate(ts: number): string {
const d = new Date(ts)
const date = d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })
const time = d.toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
})
return `${date} · ${time}`
}
// ─── Skeleton ─────────────────────────────────────────────────────────────────
function TimelineSkeleton() {
return (
<div className="flex flex-col gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-start gap-3">
<Skeleton className="mt-0.5 size-3 shrink-0 rounded-full" />
<div className="flex flex-1 flex-col gap-1.5">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-24" />
</div>
<Skeleton className="h-3 w-20 shrink-0" />
</div>
))}
</div>
)
}
// ─── Component ────────────────────────────────────────────────────────────────
export function OrderTimelineCard({ events }: Props) {
return (
<Card>
<CardHeader>
<CardTitle>Timeline</CardTitle>
</CardHeader>
<CardContent>
{events === undefined ? (
<TimelineSkeleton />
) : events.length === 0 ? (
<p className="text-sm text-muted-foreground">No activity yet.</p>
) : (
<ol className="flex flex-col">
{events.map((event, index) => {
const isLast = index === events.length - 1
const dotColor = getDotColor(event)
const sourceLabel =
SOURCE_LABELS[event.source] ?? event.source
return (
<li key={event._id} className="flex items-start gap-3">
{/* Dot + vertical connector */}
<div className="flex flex-col items-center">
<span
className={cn(
"mt-1 size-2.5 shrink-0 rounded-full border-2 border-current",
dotColor,
)}
aria-hidden="true"
/>
{!isLast && (
<span className="mt-1 w-px flex-1 bg-border" />
)}
</div>
{/* Event content */}
<div
className={cn(
"flex flex-1 items-start justify-between gap-4 pb-4",
isLast && "pb-0",
)}
>
<div className="flex flex-col gap-0.5">
<p className="text-sm">{getEventLabel(event)}</p>
<p className="text-xs text-muted-foreground">
{sourceLabel}
</p>
</div>
<time
dateTime={new Date(event.createdAt).toISOString()}
className="shrink-0 text-xs text-muted-foreground"
>
{formatEventDate(event.createdAt)}
</time>
</div>
</li>
)
})}
</ol>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,39 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface AddressSnapshot {
fullName: string
addressLine1: string
additionalInformation?: string
city: string
postalCode: string
country: string
phone?: string
}
interface Props {
address: AddressSnapshot
}
export function ShippingAddressCard({ address }: Props) {
return (
<Card>
<CardHeader>
<CardTitle>Shipping address</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-0.5">
<p className="text-sm font-medium">{address.fullName}</p>
<p className="text-sm">{address.addressLine1}</p>
{address.additionalInformation && (
<p className="text-sm">{address.additionalInformation}</p>
)}
<p className="text-sm">
{address.city}, {address.postalCode}
</p>
<p className="text-xs text-muted-foreground">{address.country}</p>
{address.phone && (
<p className="mt-1 text-sm text-muted-foreground">{address.phone}</p>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,17 @@
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { PAYMENT_STATUS_CONFIG, type PaymentStatus } from "./statusConfig"
export function OrderPaymentBadge({ status }: { status: string }) {
const config = PAYMENT_STATUS_CONFIG[status as PaymentStatus]
if (!config) {
return <span className="text-xs text-muted-foreground">{status}</span>
}
return (
<Badge variant={config.variant} className={cn(config.className)}>
{config.label}
</Badge>
)
}

View File

@@ -0,0 +1,17 @@
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
import { ORDER_STATUS_CONFIG, type OrderStatus } from "./statusConfig"
export function OrderStatusBadge({ status }: { status: string }) {
const config = ORDER_STATUS_CONFIG[status as OrderStatus]
if (!config) {
return <span className="text-xs text-muted-foreground">{status}</span>
}
return (
<Badge variant={config.variant} className={cn(config.className)}>
{config.label}
</Badge>
)
}

View File

@@ -0,0 +1,110 @@
// ─── Types ────────────────────────────────────────────────────────────────────
export type OrderStatus =
| "pending"
| "confirmed"
| "processing"
| "shipped"
| "delivered"
| "cancelled"
| "refunded"
| "return"
| "completed"
export type PaymentStatus = "pending" | "paid" | "failed" | "refunded"
export type OrderEventType =
| "status_change"
| "label_created"
| "tracking_update"
| "customer_cancel"
| "return_requested"
| "return_received"
| "return_accepted"
| "refund"
type BadgeVariant = "default" | "secondary" | "destructive" | "outline"
interface StatusConfig {
label: string
variant: BadgeVariant
className?: string
}
// ─── Order status ─────────────────────────────────────────────────────────────
export const ORDER_STATUS_CONFIG: Record<OrderStatus, StatusConfig> = {
pending: { label: "Pending", variant: "secondary" },
confirmed: {
label: "Confirmed",
variant: "outline",
className: "border-blue-500 text-blue-600",
},
processing: {
label: "Processing",
variant: "outline",
className: "border-violet-500 text-violet-600",
},
shipped: {
label: "Shipped",
variant: "outline",
className: "border-indigo-500 text-indigo-600",
},
delivered: {
label: "Delivered",
variant: "default",
className: "bg-green-600",
},
cancelled: { label: "Cancelled", variant: "destructive" },
refunded: {
label: "Refunded",
variant: "secondary",
className: "line-through",
},
return: {
label: "Return Requested",
variant: "outline",
className: "border-orange-500 text-orange-600",
},
completed: {
label: "Completed",
variant: "default",
className: "bg-teal-600",
},
}
// ─── Payment status ───────────────────────────────────────────────────────────
export const PAYMENT_STATUS_CONFIG: Record<PaymentStatus, StatusConfig> = {
pending: { label: "Pending", variant: "secondary" },
paid: { label: "Paid", variant: "default", className: "bg-green-600" },
failed: { label: "Failed", variant: "destructive" },
refunded: { label: "Refunded", variant: "secondary" },
}
// ─── Timeline event labels ────────────────────────────────────────────────────
export const EVENT_TYPE_LABELS: Record<OrderEventType, string> = {
status_change: "Status changed",
label_created: "Shipping label created",
tracking_update: "Tracking update",
customer_cancel: "Customer requested cancellation",
return_requested: "Customer requested return",
return_received: "Return marked as received",
return_accepted: "Return accepted — label created",
refund: "Refund issued",
}
// ─── Timeline dot color (status_change events only) ───────────────────────────
// Neutral dot for all other event types.
export const TIMELINE_DOT_COLOR: Partial<Record<OrderStatus, string>> = {
confirmed: "text-blue-500",
processing: "text-violet-500",
shipped: "text-indigo-500",
delivered: "text-green-600",
cancelled: "text-destructive",
refunded: "text-muted-foreground",
return: "text-orange-500",
completed: "text-teal-600",
}

View File

@@ -0,0 +1,117 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useMutation } from "convex/react"
import { api } from "../../../../../convex/_generated/api"
import type { Id } from "../../../../../convex/_generated/dataModel"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
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 {
MoreVerticalIcon,
PencilEdit01Icon,
Archive01Icon,
} from "@hugeicons/core-free-icons"
interface ProductActionsMenuProps {
productId: Id<"products">
productName: string
isArchived?: boolean
}
export function ProductActionsMenu({
productId,
productName,
isArchived,
}: ProductActionsMenuProps) {
const [archiveOpen, setArchiveOpen] = useState(false)
const [isArchiving, setIsArchiving] = useState(false)
const archive = useMutation(api.products.archive)
async function handleArchive() {
setIsArchiving(true)
try {
await archive({ id: productId })
setArchiveOpen(false)
} catch (e) {
console.error("Failed to archive product:", e)
} finally {
setIsArchiving(false)
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon"
aria-label={`Actions for ${productName}`}
/>
}
>
<HugeiconsIcon icon={MoreVerticalIcon} strokeWidth={2} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem render={<Link href={`/products/${productId}/edit`} />}>
<HugeiconsIcon icon={PencilEdit01Icon} strokeWidth={2} />
Edit
</DropdownMenuItem>
{!isArchived && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setArchiveOpen(true)}
>
<HugeiconsIcon icon={Archive01Icon} strokeWidth={2} />
Archive
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={archiveOpen} onOpenChange={setArchiveOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Archive &ldquo;{productName}&rdquo;?</AlertDialogTitle>
<AlertDialogDescription>
This product will no longer appear on the storefront. You can
restore it by editing the product later.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleArchive}
disabled={isArchiving}
>
{isArchiving ? "Archiving…" : "Archive"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,576 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { slugify } from "@repo/utils"
import Link from "next/link"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Button, buttonVariants } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowDown01Icon } from "@hugeicons/core-free-icons"
// ─── Schema ───────────────────────────────────────────────────────────────────
const formSchema = z.object({
name: z.string().min(1, "Name is required"),
slug: z
.string()
.min(1, "Slug is required")
.regex(/^[a-z0-9-]+$/, "Lowercase letters, numbers, and hyphens only"),
status: z.enum(["active", "draft", "archived"]),
categoryId: z.string().min(1, "Category is required"),
tags: z.string().default(""),
shortDescription: z.string().optional(),
description: z.string().optional(),
brand: z.string().optional(),
// Attributes — comma-separated for array fields
petSize: z.string().optional(),
ageRange: z.string().optional(),
specialDiet: z.string().optional(),
material: z.string().optional(),
flavor: z.string().optional(),
// SEO
seoTitle: z.string().optional(),
seoDescription: z.string().optional(),
canonicalSlug: z.string().optional(),
})
export type ProductFormValues = z.infer<typeof formSchema>
// ─── Types ─────────────────────────────────────────────────────────────────────
export type CategoryOption = {
_id: string
name: string
slug: string
parentId?: string
}
interface ProductFormProps {
mode: "create" | "edit"
categories: CategoryOption[]
defaultValues?: Partial<ProductFormValues>
onSubmit: (values: ProductFormValues) => Promise<void>
isSubmitting: boolean
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function parseCommaList(str?: string): string[] {
if (!str?.trim()) return []
return str
.split(",")
.map((s) => s.trim())
.filter(Boolean)
}
export function buildProductPayload(values: ProductFormValues) {
const tags = parseCommaList(values.tags)
const petSize = parseCommaList(values.petSize)
const ageRange = parseCommaList(values.ageRange)
const specialDiet = parseCommaList(values.specialDiet)
const material = values.material?.trim() || undefined
const flavor = values.flavor?.trim() || undefined
const hasAttrs =
petSize.length > 0 ||
ageRange.length > 0 ||
specialDiet.length > 0 ||
material ||
flavor
return {
name: values.name,
slug: values.slug,
status: values.status,
categoryId: values.categoryId,
tags,
description: values.description?.trim() || undefined,
shortDescription: values.shortDescription?.trim() || undefined,
brand: values.brand?.trim() || undefined,
attributes: hasAttrs
? {
...(petSize.length > 0 && { petSize }),
...(ageRange.length > 0 && { ageRange }),
...(specialDiet.length > 0 && { specialDiet }),
...(material && { material }),
...(flavor && { flavor }),
}
: undefined,
seoTitle: values.seoTitle?.trim() || undefined,
seoDescription: values.seoDescription?.trim() || undefined,
canonicalSlug: values.canonicalSlug?.trim() || undefined,
}
}
// ─── Component ────────────────────────────────────────────────────────────────
export function ProductForm({
mode,
categories,
defaultValues,
onSubmit,
isSubmitting,
}: ProductFormProps) {
const [slugManuallySet, setSlugManuallySet] = useState(mode === "edit")
const form = useForm<ProductFormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
slug: "",
status: "draft",
categoryId: "",
tags: "",
shortDescription: "",
description: "",
brand: "",
petSize: "",
ageRange: "",
specialDiet: "",
material: "",
flavor: "",
seoTitle: "",
seoDescription: "",
canonicalSlug: "",
...defaultValues,
},
})
// Auto-derive slug from name in create mode
const nameValue = form.watch("name")
useEffect(() => {
if (!slugManuallySet && nameValue) {
form.setValue("slug", slugify(nameValue), { shouldValidate: false })
}
}, [nameValue, slugManuallySet, form])
// Build category options with "Parent / Child" display labels
const categoryOptions = useMemo(() => {
const idToName: Record<string, string> = {}
for (const cat of categories) idToName[cat._id] = cat.name
return categories
.filter((cat) => cat.parentId)
.map((cat) => ({
value: cat._id,
label: `${idToName[cat.parentId!] ?? "?"} / ${cat.name}`,
}))
.sort((a, b) => a.label.localeCompare(b.label))
}, [categories])
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* ── Core ───────────────────────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Core
</h2>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Royal Canin Adult Cat" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
placeholder="royal-canin-adult-cat"
{...field}
onChange={(e) => {
setSlugManuallySet(true)
field.onChange(e)
}}
/>
</FormControl>
<FormDescription>
URL-safe identifier. Auto-generated from name; edit to
override.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select
value={field.value}
onValueChange={(v) => field.onChange(v)}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<Select
value={field.value}
onValueChange={(v) => field.onChange(v)}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select category" />
</SelectTrigger>
</FormControl>
<SelectContent>
{categories.length === 0 ? (
<SelectItem value="_loading" disabled>
Loading
</SelectItem>
) : (
categoryOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="tags"
render={({ field }) => (
<FormItem>
<FormLabel>Tags</FormLabel>
<FormControl>
<Input placeholder="cat-food, dry, indoor" {...field} />
</FormControl>
<FormDescription>Comma-separated list of tags.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</section>
<Separator />
{/* ── Details ────────────────────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Details
</h2>
<FormField
control={form.control}
name="brand"
render={({ field }) => (
<FormItem>
<FormLabel>Brand</FormLabel>
<FormControl>
<Input placeholder="Royal Canin" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="shortDescription"
render={({ field }) => (
<FormItem>
<FormLabel>Short description</FormLabel>
<FormControl>
<Textarea
placeholder="One or two sentences shown in listings…"
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Full description</FormLabel>
<FormControl>
<Textarea
placeholder="Detailed product description…"
className="min-h-32 resize-y"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</section>
<Separator />
{/* ── Attributes (collapsible) ────────────────────────────────── */}
<Collapsible className="group/collapsible">
<CollapsibleTrigger className="flex w-full items-center justify-between py-1 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
<span className="uppercase tracking-wide">Attributes</span>
<HugeiconsIcon
icon={ArrowDown01Icon}
strokeWidth={2}
className="size-4 transition-transform group-data-[open]/collapsible:rotate-180"
/>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="petSize"
render={({ field }) => (
<FormItem>
<FormLabel>Pet size</FormLabel>
<FormControl>
<Input placeholder="small, medium, large" {...field} />
</FormControl>
<FormDescription>Comma-separated.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="ageRange"
render={({ field }) => (
<FormItem>
<FormLabel>Age range</FormLabel>
<FormControl>
<Input placeholder="puppy, adult, senior" {...field} />
</FormControl>
<FormDescription>Comma-separated.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="specialDiet"
render={({ field }) => (
<FormItem>
<FormLabel>Special diet</FormLabel>
<FormControl>
<Input
placeholder="grain-free, hypoallergenic"
{...field}
/>
</FormControl>
<FormDescription>Comma-separated.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="material"
render={({ field }) => (
<FormItem>
<FormLabel>Material</FormLabel>
<FormControl>
<Input placeholder="Nylon" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="flavor"
render={({ field }) => (
<FormItem>
<FormLabel>Flavor</FormLabel>
<FormControl>
<Input placeholder="Chicken" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</CollapsibleContent>
</Collapsible>
<Separator />
{/* ── SEO / Advanced (collapsible) ───────────────────────────── */}
<Collapsible className="group/collapsible">
<CollapsibleTrigger className="flex w-full items-center justify-between py-1 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
<span className="uppercase tracking-wide">Advanced / SEO</span>
<HugeiconsIcon
icon={ArrowDown01Icon}
strokeWidth={2}
className="size-4 transition-transform group-data-[open]/collapsible:rotate-180"
/>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-4">
<FormField
control={form.control}
name="seoTitle"
render={({ field }) => (
<FormItem>
<FormLabel>SEO title</FormLabel>
<FormControl>
<Input
placeholder="Overrides product name in search results"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="seoDescription"
render={({ field }) => (
<FormItem>
<FormLabel>SEO description</FormLabel>
<FormControl>
<Textarea
placeholder="Meta description for search engines…"
className="resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="canonicalSlug"
render={({ field }) => (
<FormItem>
<FormLabel>Canonical slug</FormLabel>
<FormControl>
<Input
placeholder="Overrides slug for canonical URL"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
{/* ── Actions ────────────────────────────────────────────────── */}
<div className="flex items-center gap-3 pt-2">
<Button type="submit" disabled={isSubmitting}>
{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
? mode === "create"
? "Creating…"
: "Saving…"
: mode === "create"
? "Create product"
: "Save changes"}
</Button>
<Link
href="/products"
className={buttonVariants({ variant: "outline" })}
>
Cancel
</Link>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,231 @@
"use client"
import React from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
export type PreviewProduct = {
_id: string
name: string
slug: string
status: "active" | "draft" | "archived"
description?: string
shortDescription?: string
brand?: string
tags: string[]
parentCategorySlug?: string
childCategorySlug?: string
topCategorySlug?: string
attributes?: {
petSize?: string[]
ageRange?: string[]
specialDiet?: string[]
material?: string
flavor?: string
}
seoTitle?: string
seoDescription?: string
canonicalSlug?: string
createdAt?: number
updatedAt?: number
averageRating?: number
reviewCount?: number
}
const STATUS_CONFIG = {
active: { label: "Active", variant: "default" as const },
draft: { label: "Draft", variant: "secondary" as const },
archived: { label: "Archived", variant: "outline" as const },
}
function formatDate(ms?: number) {
if (!ms) return "—"
return new Date(ms).toLocaleString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
function InfoRow({
label,
children,
}: {
label: string
children?: React.ReactNode
}) {
if (children == null || children === "") return null
return (
<div className="grid grid-cols-[128px_1fr] gap-2 py-0.5">
<span className="pt-0.5 text-xs text-muted-foreground">{label}</span>
<span className="break-words text-sm">{children}</span>
</div>
)
}
interface ProductPreviewDialogProps {
product: PreviewProduct | null
open: boolean
onOpenChange: (open: boolean) => void
}
export function ProductPreviewDialog({
product,
open,
onOpenChange,
}: ProductPreviewDialogProps) {
if (!product) return null
const statusCfg = STATUS_CONFIG[product.status]
const attrs = product.attributes
const hasAttrs =
attrs &&
((attrs.petSize?.length ?? 0) > 0 ||
(attrs.ageRange?.length ?? 0) > 0 ||
(attrs.specialDiet?.length ?? 0) > 0 ||
attrs.material ||
attrs.flavor)
const hasSeo =
product.seoTitle || product.seoDescription || product.canonicalSlug
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{product.name}</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="space-y-3 py-1">
{/* Core fields */}
<div className="space-y-0.5">
<InfoRow label="Status">
<Badge variant={statusCfg.variant}>{statusCfg.label}</Badge>
</InfoRow>
<InfoRow label="Slug">{product.slug}</InfoRow>
<InfoRow label="Brand">{product.brand}</InfoRow>
</div>
<Separator />
{/* Category */}
<div className="space-y-0.5">
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Category
</p>
<InfoRow label="Parent">{product.parentCategorySlug}</InfoRow>
<InfoRow label="Child">{product.childCategorySlug}</InfoRow>
<InfoRow label="Top">{product.topCategorySlug}</InfoRow>
</div>
{(product.shortDescription || product.description) && (
<>
<Separator />
<div>
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Description
</p>
<InfoRow label="Short">{product.shortDescription}</InfoRow>
{product.description && (
<div className="py-0.5">
<p className="mb-1 text-xs text-muted-foreground">Full</p>
<p className="whitespace-pre-wrap text-sm leading-relaxed">
{product.description}
</p>
</div>
)}
</div>
</>
)}
{product.tags.length > 0 && (
<>
<Separator />
<InfoRow label="Tags">
<div className="flex flex-wrap gap-1">
{product.tags.map((t) => (
<Badge key={t} variant="secondary">
{t}
</Badge>
))}
</div>
</InfoRow>
</>
)}
{hasAttrs && (
<>
<Separator />
<div className="space-y-0.5">
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Attributes
</p>
{(attrs?.petSize?.length ?? 0) > 0 && (
<InfoRow label="Pet size">{attrs!.petSize!.join(", ")}</InfoRow>
)}
{(attrs?.ageRange?.length ?? 0) > 0 && (
<InfoRow label="Age range">{attrs!.ageRange!.join(", ")}</InfoRow>
)}
{(attrs?.specialDiet?.length ?? 0) > 0 && (
<InfoRow label="Special diet">
{attrs!.specialDiet!.join(", ")}
</InfoRow>
)}
{attrs?.material && (
<InfoRow label="Material">{attrs.material}</InfoRow>
)}
{attrs?.flavor && (
<InfoRow label="Flavor">{attrs.flavor}</InfoRow>
)}
</div>
</>
)}
{hasSeo && (
<>
<Separator />
<div className="space-y-0.5">
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
SEO
</p>
<InfoRow label="SEO title">{product.seoTitle}</InfoRow>
<InfoRow label="SEO description">{product.seoDescription}</InfoRow>
<InfoRow label="Canonical slug">{product.canonicalSlug}</InfoRow>
</div>
</>
)}
<Separator />
{/* Meta */}
<div className="space-y-0.5">
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Meta
</p>
<InfoRow label="Created">{formatDate(product.createdAt)}</InfoRow>
<InfoRow label="Updated">{formatDate(product.updatedAt)}</InfoRow>
{product.averageRating != null && (
<InfoRow label="Rating">
{product.averageRating.toFixed(1)} ({product.reviewCount ?? 0}{" "}
reviews)
</InfoRow>
)}
</div>
</div>
</ScrollArea>
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
)
}

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 "../ui/input"
import { Skeleton } from "../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,40 @@
import Image from "next/image";
import Link from "next/link";
export default function StillBuildingPlaceholder() {
return (
<main className="flex flex-1 items-center justify-center bg-[var(--background)] px-4 py-16 md:px-6 md:py-24">
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-8 md:flex-row md:gap-12">
<div className="flex w-full flex-col items-center text-center md:w-1/2 md:items-start md:text-left">
<h2 className="text-2xl font-black tracking-tight md:text-3xl flex flex-wrap gap-x-3 gap-y-0 items-baseline">
<span className="font-extralight">Building</span>
<span>in Progress</span>
</h2>
<p className="mt-4 max-w-md text-base leading-relaxed md:text-lg">
Something pawsome is coming to this page! We&apos;re still crafting
it. Try poking around on other pages in the meantime.
</p>
<Link
href="/"
className="mt-8 inline-flex items-center rounded-full bg-primary px-8 py-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 md:w-auto"
>
Back to Dashboard
</Link>
</div>
<div className="order-first flex w-full max-w-xs flex-col items-center gap-2 md:order-last md:w-1/2 md:max-w-none">
<Image
src="/illustrations/still_building.svg"
alt="Page still being built illustration"
width={500}
height={500}
priority
className="h-auto w-full"
/>
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}

View File

@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,125 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowRight01Icon, MoreHorizontalCircle01Icon } from "@hugeicons/core-free-icons"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
function BreadcrumbLink({
className,
render,
...props
}: useRender.ComponentProps<"a">) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn("transition-colors hover:text-foreground", className),
},
props
),
render,
state: {
slot: "breadcrumb-link",
},
})
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} />
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"flex size-5 items-center justify-center [&>svg]:size-4",
className
)}
{...props}
>
<HugeiconsIcon icon={MoreHorizontalCircle01Icon} strokeWidth={2} />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,60 @@
"use client"
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,103 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

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,
}

View File

@@ -0,0 +1,29 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { Tick02Icon } from "@hugeicons/core-free-icons"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,21 @@
"use client"
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
)
}
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

View File

@@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { HugeiconsIcon } from "@hugeicons/react"
import { Cancel01Icon } from "@hugeicons/core-free-icons"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,270 @@
"use client"
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { ArrowRight01Icon, Tick02Icon } from "@hugeicons/core-free-icons"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn(
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,136 @@
"use client"
import * as React from "react"
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = { name: TName }
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const fieldState = getFieldState(fieldContext.name, formState)
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = { id: string }
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div data-slot="form-item" className={cn("grid gap-2", className)} {...props} />
</FormItemContext.Provider>
)
}
function FormLabel({ className, ...props }: React.ComponentProps<"label">) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ children }: { children: React.ReactElement<React.HTMLAttributes<HTMLElement>> }) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return React.cloneElement(children, {
id: formItemId,
"aria-describedby": !error
? formDescriptionId
: `${formDescriptionId} ${formMessageId}`,
"aria-invalid": !!error,
})
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) return null
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { HugeiconsIcon } from "@hugeicons/react"
import { UnfoldMoreIcon, Tick02Icon, ArrowUp01Icon, ArrowDown01Icon } from "@hugeicons/core-free-icons"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<HugeiconsIcon icon={UnfoldMoreIcon} strokeWidth={2} className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} />
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} />
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { HugeiconsIcon } from "@hugeicons/react"
import { Cancel01Icon } from "@hugeicons/core-free-icons"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-base font-medium text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,724 @@
"use client"
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { HugeiconsIcon } from "@hugeicons/react"
import { SidebarLeftIcon } from "@hugeicons/core-free-icons"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
className
)}
{...props}
>
{children}
</div>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
dir={dir}
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer hidden text-sidebar-foreground md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
data-side={side}
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<HugeiconsIcon icon={SidebarLeftIcon} strokeWidth={2} />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("h-8 w-full bg-background shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("mx-2 w-auto bg-sidebar-border", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
render,
...props
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-label",
sidebar: "group-label",
},
})
}
function SidebarGroupAction({
className,
render,
...props
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-group-action",
sidebar: "group-action",
},
})
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const { isMobile, state } = useSidebar()
const comp = useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
},
props
),
render: !tooltip ? render : <TooltipTrigger render={render} />,
state: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size,
active: isActive,
},
})
if (!tooltip) {
return comp
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
{comp}
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
render,
showOnHover = false,
...props
}: useRender.ComponentProps<"button"> &
React.ComponentProps<"button"> & {
showOnHover?: boolean
}) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
className: cn(
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
showOnHover &&
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-action",
sidebar: "menu-action",
},
})
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
})
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
render,
size = "md",
isActive = false,
className,
...props
}: useRender.ComponentProps<"a"> &
React.ComponentProps<"a"> & {
size?: "sm" | "md"
isActive?: boolean
}) {
return useRender({
defaultTagName: "a",
props: mergeProps<"a">(
{
className: cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
className
),
},
props
),
render,
state: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size,
active: isActive,
},
})
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,32 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: SwitchPrimitive.Root.Props & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }

View File

@@ -0,0 +1,66 @@
"use client"
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 w-fit max-w-xs origin-(--transform-origin) rounded-md bg-foreground px-3 py-1.5 text-xs text-background data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,126 @@
"use client"
import { useState } from "react"
import { useMutation } from "convex/react"
import { toast } from "sonner"
import { api } from "../../../../../convex/_generated/api"
import type { Id } from "../../../../../convex/_generated/dataModel"
import { dollarsToCents } from "@repo/utils"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog"
import { Button } from "../ui/button"
import { ScrollArea } from "../ui/scroll-area"
import { VariantForm, CREATE_DEFAULTS, type VariantFormValues } from "./VariantForm"
const FORM_ID = "create-variant-form"
interface CreateVariantDialogProps {
productId: Id<"products">
productName: string
brand?: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function CreateVariantDialog({
productId,
productName,
brand,
open,
onOpenChange,
}: CreateVariantDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false)
const addVariant = useMutation(api.products.addVariant)
async function handleSubmit(values: VariantFormValues) {
setIsSubmitting(true)
try {
const attrSize = values.attrSize?.trim() || undefined
const attrFlavor = values.attrFlavor?.trim() || undefined
const attrColor = values.attrColor?.trim() || undefined
const hasAttrs = attrSize || attrFlavor || attrColor
await addVariant({
productId,
name: values.name,
sku: values.sku,
price: dollarsToCents(values.price),
compareAtPrice: values.onSale && values.compareAtPrice != null
? dollarsToCents(values.compareAtPrice)
: undefined,
stockQuantity: values.stockQuantity,
isActive: values.isActive,
weight: values.weight,
weightUnit: values.weightUnit,
attributes: hasAttrs
? { size: attrSize, flavor: attrFlavor, color: attrColor }
: undefined,
length: values.length,
width: values.width,
height: values.height,
dimensionUnit: values.dimensionUnit,
})
toast.success(`Variant "${values.name}" created`)
onOpenChange(false)
} catch (e: any) {
toast.error(e?.message ?? "Failed to create variant")
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Create variant</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[65vh] pr-1">
<div className="pb-2">
<VariantForm
formId={FORM_ID}
defaultValues={CREATE_DEFAULTS}
onSubmit={handleSubmit}
productName={productName}
brand={brand}
mode="create"
/>
</div>
</ScrollArea>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
{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 ? "Creating…" : "Create variant"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,129 @@
"use client"
import { useState } from "react"
import { useMutation } from "convex/react"
import { toast } from "sonner"
import { api } from "../../../../../convex/_generated/api"
import { dollarsToCents } from "@repo/utils"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog"
import { Button } from "../ui/button"
import { ScrollArea } from "../ui/scroll-area"
import { VariantForm, variantToFormValues, type VariantFormValues } from "./VariantForm"
import type { Variant } from "./VariantsTable"
const FORM_ID = "edit-variant-form"
interface EditVariantDialogProps {
variant: Variant | null
productName: string
brand?: string
open: boolean
onOpenChange: (open: boolean) => void
}
export function EditVariantDialog({
variant,
productName,
brand,
open,
onOpenChange,
}: EditVariantDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false)
const updateVariant = useMutation(api.products.updateVariant)
async function handleSubmit(values: VariantFormValues) {
if (!variant) return
setIsSubmitting(true)
try {
const attrSize = values.attrSize?.trim() || undefined
const attrFlavor = values.attrFlavor?.trim() || undefined
const attrColor = values.attrColor?.trim() || undefined
const hasAttrs = attrSize || attrFlavor || attrColor
await updateVariant({
id: variant._id,
name: values.name,
sku: values.sku,
price: dollarsToCents(values.price),
compareAtPrice: values.onSale && values.compareAtPrice != null
? dollarsToCents(values.compareAtPrice)
: undefined,
stockQuantity: values.stockQuantity,
isActive: values.isActive,
weight: values.weight,
weightUnit: values.weightUnit,
attributes: hasAttrs
? { size: attrSize, flavor: attrFlavor, color: attrColor }
: undefined,
length: values.length,
width: values.width,
height: values.height,
dimensionUnit: values.dimensionUnit,
})
toast.success(`"${values.name}" updated`)
onOpenChange(false)
} catch (e: any) {
toast.error(e?.message ?? "Failed to update variant")
} finally {
setIsSubmitting(false)
}
}
if (!variant) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Edit variant</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[65vh] pr-1">
<div className="pb-2">
<VariantForm
formId={FORM_ID}
defaultValues={variantToFormValues(variant)}
onSubmit={handleSubmit}
productName={productName}
brand={brand}
mode="edit"
/>
</div>
</ScrollArea>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
{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 ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,486 @@
"use client"
import { useEffect, useState } from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { centsToDollars, generateSku } from "@repo/utils"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "../ui/form"
import { Input } from "../ui/input"
import { Switch } from "../ui/switch"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select"
import { Separator } from "../ui/separator"
import type { Variant } from "./VariantsTable"
// ─── Schema ───────────────────────────────────────────────────────────────────
const optionalPositiveNum = z.preprocess(
(v) => (v === "" || v == null ? undefined : Number(v)),
z.number().positive().optional(),
) as z.ZodType<number | undefined>
export const variantFormSchema = z
.object({
name: z.string().min(1, "Name is required"),
sku: z.string().min(1, "SKU is required"),
price: z.coerce.number({ invalid_type_error: "Enter a valid price" }).positive("Price must be greater than 0"),
stockQuantity: z.coerce.number({ invalid_type_error: "Enter a valid number" }).int().min(0, "Stock must be 0 or more"),
isActive: z.boolean(),
weight: z.coerce.number({ invalid_type_error: "Enter a valid weight" }).positive("Weight must be greater than 0"),
weightUnit: z.enum(["g", "kg", "lb", "oz"]),
onSale: z.boolean(),
compareAtPrice: optionalPositiveNum,
attrSize: z.string().optional(),
attrFlavor: z.string().optional(),
attrColor: z.string().optional(),
length: optionalPositiveNum,
width: optionalPositiveNum,
height: optionalPositiveNum,
dimensionUnit: z.enum(["cm", "in"]).optional(),
})
.superRefine((data, ctx) => {
if (data.onSale && !data.compareAtPrice) {
ctx.addIssue({
code: "custom",
path: ["compareAtPrice"],
message: "Compare at price is required when on sale",
})
}
const hasDim = data.length != null || data.width != null || data.height != null
if (hasDim) {
if (data.length == null)
ctx.addIssue({ code: "custom", path: ["length"], message: "Required when any dimension is set" })
if (data.width == null)
ctx.addIssue({ code: "custom", path: ["width"], message: "Required when any dimension is set" })
if (data.height == null)
ctx.addIssue({ code: "custom", path: ["height"], message: "Required when any dimension is set" })
if (!data.dimensionUnit)
ctx.addIssue({ code: "custom", path: ["dimensionUnit"], message: "Required when dimensions are set" })
}
})
export type VariantFormValues = z.infer<typeof variantFormSchema>
// ─── Helpers ──────────────────────────────────────────────────────────────────
export function variantToFormValues(variant: Variant): VariantFormValues {
return {
name: variant.name,
sku: variant.sku,
price: centsToDollars(variant.price),
stockQuantity: variant.stockQuantity,
isActive: variant.isActive,
weight: variant.weight,
weightUnit: variant.weightUnit,
onSale: variant.compareAtPrice != null,
compareAtPrice: variant.compareAtPrice != null ? centsToDollars(variant.compareAtPrice) : undefined,
attrSize: variant.attributes?.size ?? "",
attrFlavor: variant.attributes?.flavor ?? "",
attrColor: variant.attributes?.color ?? "",
length: variant.length,
width: variant.width,
height: variant.height,
dimensionUnit: variant.dimensionUnit ?? "cm",
}
}
export const CREATE_DEFAULTS: VariantFormValues = {
name: "",
sku: "",
price: 0,
stockQuantity: 0,
isActive: true,
weight: 0,
weightUnit: "kg",
onSale: false,
compareAtPrice: undefined,
attrSize: "",
attrFlavor: "",
attrColor: "",
length: undefined,
width: undefined,
height: undefined,
dimensionUnit: "cm",
}
// ─── Component ────────────────────────────────────────────────────────────────
interface VariantFormProps {
formId: string
defaultValues: VariantFormValues
onSubmit: (values: VariantFormValues) => Promise<void>
productName?: string
brand?: string
/** In edit mode the SKU starts as manually set — auto-gen is off by default */
mode?: "create" | "edit"
}
export function VariantForm({
formId,
defaultValues,
onSubmit,
productName = "",
brand = "",
mode = "create",
}: VariantFormProps) {
const [skuManuallyEdited, setSkuManuallyEdited] = useState(mode === "edit")
const form = useForm<VariantFormValues>({
resolver: zodResolver(variantFormSchema),
defaultValues,
})
// Reset when defaultValues change (e.g. switching from one variant to another)
useEffect(() => {
form.reset(defaultValues)
setSkuManuallyEdited(mode === "edit")
}, [defaultValues]) // eslint-disable-line react-hooks/exhaustive-deps
// Auto-generate SKU from variant-level details only
const [attrFlavor, attrSize, attrColor, weight, weightUnit] = form.watch([
"attrFlavor",
"attrSize",
"attrColor",
"weight",
"weightUnit",
])
useEffect(() => {
if (skuManuallyEdited) return
const suggested = generateSku(
brand,
productName,
{ flavor: attrFlavor, size: attrSize, color: attrColor },
weight || undefined,
weightUnit,
)
if (suggested) form.setValue("sku", suggested)
}, [brand, productName, attrFlavor, attrSize, attrColor, weight, weightUnit, skuManuallyEdited]) // eslint-disable-line react-hooks/exhaustive-deps
const onSale = form.watch("onSale")
const length = form.watch("length")
const width = form.watch("width")
const height = form.watch("height")
const showDimensionUnit = length != null || width != null || height != null
return (
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
{/* ── Identity ─────────────────────────────────────────────── */}
<div className="grid grid-cols-2 items-start gap-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="500g bag" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sku"
render={({ field }) => (
<FormItem>
<FormLabel>SKU</FormLabel>
<FormControl>
<Input
placeholder="RC-ADG-5KG"
{...field}
onChange={(e) => {
setSkuManuallyEdited(true)
field.onChange(e.target.value.toUpperCase())
}}
/>
</FormControl>
<FormDescription>
{skuManuallyEdited ? "Manually set." : "Auto-generated from details."}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<Separator />
{/* ── Pricing ──────────────────────────────────────────────── */}
<div className="space-y-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Pricing</p>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="price"
render={({ field }) => (
<FormItem>
<FormLabel>Price (£)</FormLabel>
<FormControl>
<Input type="number" step="0.01" min="0" placeholder="19.99" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="onSale"
render={({ field }) => (
<FormItem className="flex flex-col justify-end pb-1">
<FormLabel>On sale</FormLabel>
<div className="flex items-center gap-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<span className="text-sm text-muted-foreground">
{field.value ? "Yes" : "No"}
</span>
</div>
</FormItem>
)}
/>
</div>
{onSale && (
<FormField
control={form.control}
name="compareAtPrice"
render={({ field }) => (
<FormItem>
<FormLabel>Compare at price (£)</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
min="0"
placeholder="24.99"
value={field.value ?? ""}
onChange={field.onChange}
/>
</FormControl>
<FormDescription>Original price shown as strikethrough.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
<Separator />
{/* ── Inventory ────────────────────────────────────────────── */}
<div className="space-y-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Inventory</p>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="stockQuantity"
render={({ field }) => (
<FormItem>
<FormLabel>Stock quantity</FormLabel>
<FormControl>
<Input type="number" min="0" step="1" placeholder="0" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="isActive"
render={({ field }) => (
<FormItem className="flex flex-col justify-end pb-1">
<FormLabel>Active</FormLabel>
<div className="flex items-center gap-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<span className="text-sm text-muted-foreground">
{field.value ? "Visible" : "Hidden"}
</span>
</div>
</FormItem>
)}
/>
</div>
</div>
<Separator />
{/* ── Shipping ─────────────────────────────────────────────── */}
<div className="space-y-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Shipping</p>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="weight"
render={({ field }) => (
<FormItem>
<FormLabel>Weight</FormLabel>
<FormControl>
<Input type="number" step="0.001" min="0" placeholder="0.5" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="weightUnit"
render={({ field }) => (
<FormItem>
<FormLabel>Unit</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="kg">kg</SelectItem>
<SelectItem value="g">g</SelectItem>
<SelectItem value="lb">lb</SelectItem>
<SelectItem value="oz">oz</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Dimensions */}
<div className="grid grid-cols-3 gap-3">
{(["length", "width", "height"] as const).map((dim) => (
<FormField
key={dim}
control={form.control}
name={dim}
render={({ field }) => (
<FormItem>
<FormLabel className="capitalize">{dim}</FormLabel>
<FormControl>
<Input
type="number"
step="0.1"
min="0"
placeholder="—"
value={field.value ?? ""}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
{showDimensionUnit && (
<FormField
control={form.control}
name="dimensionUnit"
render={({ field }) => (
<FormItem className="max-w-[120px]">
<FormLabel>Dimension unit</FormLabel>
<Select value={field.value ?? "cm"} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="cm">cm</SelectItem>
<SelectItem value="in">in</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
<Separator />
{/* ── Attributes (optional) ────────────────────────────────── */}
<div className="space-y-4">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Attributes <span className="normal-case font-normal">(optional)</span>
</p>
<div className="grid grid-cols-3 gap-3">
<FormField
control={form.control}
name="attrSize"
render={({ field }) => (
<FormItem>
<FormLabel>Size</FormLabel>
<FormControl>
<Input placeholder="500g" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="attrFlavor"
render={({ field }) => (
<FormItem>
<FormLabel>Flavor</FormLabel>
<FormControl>
<Input placeholder="Chicken" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="attrColor"
render={({ field }) => (
<FormItem>
<FormLabel>Color</FormLabel>
<FormControl>
<Input placeholder="Red" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,143 @@
"use client"
import { formatPrice } from "@repo/utils"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog"
import { Badge } from "../ui/badge"
import { ScrollArea } from "../ui/scroll-area"
import { Separator } from "../ui/separator"
import type { Variant } from "./VariantsTable"
function InfoRow({
label,
children,
}: {
label: string
children?: React.ReactNode
}) {
if (children == null || children === "") return null
return (
<div className="grid grid-cols-[140px_1fr] gap-2 py-0.5">
<span className="pt-0.5 text-xs text-muted-foreground">{label}</span>
<span className="break-words text-sm">{children}</span>
</div>
)
}
interface VariantPreviewDialogProps {
variant: Variant | null
open: boolean
onOpenChange: (open: boolean) => void
}
export function VariantPreviewDialog({
variant,
open,
onOpenChange,
}: VariantPreviewDialogProps) {
if (!variant) return null
const hasDimensions =
variant.length != null || variant.width != null || variant.height != null
const hasAttributes =
variant.attributes?.size ||
variant.attributes?.flavor ||
variant.attributes?.color
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{variant.name}</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<div className="space-y-3 py-1">
{/* Core */}
<div className="space-y-0.5">
<InfoRow label="SKU">
<span className="font-mono text-xs">{variant.sku}</span>
</InfoRow>
<InfoRow label="Status">
<Badge variant={variant.isActive ? "default" : "secondary"}>
{variant.isActive ? "Active" : "Inactive"}
</Badge>
</InfoRow>
</div>
<Separator />
{/* Pricing */}
<div className="space-y-0.5">
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Pricing
</p>
<InfoRow label="Price">{formatPrice(variant.price)}</InfoRow>
<InfoRow label="Compare at">
{variant.compareAtPrice ? formatPrice(variant.compareAtPrice) : "—"}
</InfoRow>
</div>
<Separator />
{/* Inventory */}
<div className="space-y-0.5">
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Inventory
</p>
<InfoRow label="Stock quantity">{variant.stockQuantity}</InfoRow>
</div>
<Separator />
{/* Shipping */}
<div className="space-y-0.5">
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Shipping
</p>
<InfoRow label="Weight">
{variant.weight} {variant.weightUnit}
</InfoRow>
{hasDimensions && (
<InfoRow label="Dimensions">
{[variant.length, variant.width, variant.height]
.map((d) => d ?? "?")
.join(" × ")}{" "}
{variant.dimensionUnit ?? "cm"}
</InfoRow>
)}
</div>
{hasAttributes && (
<>
<Separator />
<div className="space-y-0.5">
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Attributes
</p>
{variant.attributes?.size && (
<InfoRow label="Size">{variant.attributes.size}</InfoRow>
)}
{variant.attributes?.flavor && (
<InfoRow label="Flavor">{variant.attributes.flavor}</InfoRow>
)}
{variant.attributes?.color && (
<InfoRow label="Color">{variant.attributes.color}</InfoRow>
)}
</div>
</>
)}
</div>
</ScrollArea>
<DialogFooter showCloseButton />
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,259 @@
"use client"
import { useState } from "react"
import { useMutation } from "convex/react"
import { toast } from "sonner"
import { api } from "../../../../../convex/_generated/api"
import type { Id } from "../../../../../convex/_generated/dataModel"
import { formatPrice } from "@repo/utils"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table"
import { Badge } from "../ui/badge"
import { Button } from "../ui/button"
import { Skeleton } from "../ui/skeleton"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../ui/dropdown-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog"
import { HugeiconsIcon } from "@hugeicons/react"
import {
MoreVerticalIcon,
PencilEdit01Icon,
Delete01Icon,
CheckmarkCircle01Icon,
Cancel01Icon,
} from "@hugeicons/core-free-icons"
export interface Variant {
_id: Id<"productVariants">
name: string
sku: string
price: number
compareAtPrice?: number
stockQuantity: number
isActive: boolean
weight: number
weightUnit: "g" | "kg" | "lb" | "oz"
attributes?: { size?: string; flavor?: string; color?: string }
length?: number
width?: number
height?: number
dimensionUnit?: "cm" | "in"
}
interface VariantsTableProps {
variants: Variant[]
onPreview: (variant: Variant) => void
onEdit: (variant: Variant) => void
}
function TableSkeleton() {
return (
<>
{Array.from({ length: 6 }).map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
<TableCell><Skeleton className="h-4 w-10" /></TableCell>
<TableCell><Skeleton className="h-5 w-16 rounded-full" /></TableCell>
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
<TableCell><Skeleton className="size-7 rounded-lg" /></TableCell>
</TableRow>
))}
</>
)
}
function VariantActionsMenu({
variant,
onEdit,
}: {
variant: Variant
onEdit: (variant: Variant) => void
}) {
const [deleteOpen, setDeleteOpen] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isToggling, setIsToggling] = useState(false)
const deleteVariant = useMutation(api.products.deleteVariant)
const updateVariant = useMutation(api.products.updateVariant)
async function handleToggleActive() {
setIsToggling(true)
try {
await updateVariant({ id: variant._id, isActive: !variant.isActive })
toast.success(`"${variant.name}" ${variant.isActive ? "deactivated" : "activated"}`)
} catch (e: any) {
toast.error(e?.message ?? "Failed to update variant")
} finally {
setIsToggling(false)
}
}
async function handleDelete() {
setIsDeleting(true)
try {
await deleteVariant({ id: variant._id })
toast.success(`"${variant.name}" deleted`)
setDeleteOpen(false)
} catch (e: any) {
toast.error(e?.message ?? "Failed to delete variant")
} finally {
setIsDeleting(false)
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon"
aria-label={`Actions for ${variant.name}`}
/>
}
>
<HugeiconsIcon icon={MoreVerticalIcon} strokeWidth={2} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(variant)}>
<HugeiconsIcon icon={PencilEdit01Icon} strokeWidth={2} />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleToggleActive}
disabled={isToggling}
>
<HugeiconsIcon
icon={variant.isActive ? Cancel01Icon : CheckmarkCircle01Icon}
strokeWidth={2}
/>
{variant.isActive ? "Deactivate" : "Activate"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => setDeleteOpen(true)}
>
<HugeiconsIcon icon={Delete01Icon} strokeWidth={2} />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete &ldquo;{variant.name}&rdquo;?</AlertDialogTitle>
<AlertDialogDescription>
{variant.isActive
? "This variant will be permanently deleted. If it appears in existing orders, it will be deactivated instead."
: "This variant will be permanently deleted."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export function VariantsTable({ variants, onPreview, onEdit }: VariantsTableProps) {
return (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead scope="col">Name</TableHead>
<TableHead scope="col">SKU</TableHead>
<TableHead scope="col">Price</TableHead>
<TableHead scope="col">Compare at</TableHead>
<TableHead scope="col">Stock</TableHead>
<TableHead scope="col">Status</TableHead>
<TableHead scope="col">Weight</TableHead>
<TableHead scope="col" className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{variants.length === 0 ? (
<TableRow>
<TableCell
colSpan={8}
className="py-16 text-center text-sm text-muted-foreground"
>
No variants yet. Create the first variant for this product.
</TableCell>
</TableRow>
) : (
variants.map((variant) => (
<TableRow key={variant._id}>
<TableCell>
<button
type="button"
className="font-medium hover:underline text-left"
onClick={() => onPreview(variant)}
>
{variant.name}
</button>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{variant.sku}
</TableCell>
<TableCell>{formatPrice(variant.price)}</TableCell>
<TableCell className="text-muted-foreground">
{variant.compareAtPrice ? formatPrice(variant.compareAtPrice) : "—"}
</TableCell>
<TableCell>{variant.stockQuantity}</TableCell>
<TableCell>
<Badge variant={variant.isActive ? "default" : "secondary"}>
{variant.isActive ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{variant.weight} {variant.weightUnit}
</TableCell>
<TableCell>
<VariantActionsMenu variant={variant} onEdit={onEdit} />
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)
}
export { TableSkeleton as VariantsTableSkeleton }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}

View File

@@ -0,0 +1,26 @@
"use client";
import { useConvexAuth, useQuery } from "convex/react";
import { api } from "../../../../convex/_generated/api";
type AdminAuthState =
| { status: "loading" }
| { status: "authorized"; role: "admin" | "super_admin" }
| { status: "denied"; reason: "not_authenticated" | "not_admin" | "no_user_record" };
export function useAdminAuth(): AdminAuthState {
const { isLoading, isAuthenticated } = useConvexAuth();
const user = useQuery(
api.users.current,
isAuthenticated ? {} : "skip",
);
if (isLoading) return { status: "loading" };
if (!isAuthenticated) return { status: "denied", reason: "not_authenticated" };
if (user === undefined) return { status: "loading" };
if (user === null) return { status: "denied", reason: "no_user_record" };
if (user.role !== "admin" && user.role !== "super_admin") {
return { status: "denied", reason: "not_admin" };
}
return { status: "authorized", role: user.role };
}

View File

@@ -0,0 +1,31 @@
"use client";
import { useUser } from "@clerk/nextjs";
import { useConvexAuth, useMutation } from "convex/react";
import { useEffect, useState } from "react";
import { api } from "../../../../convex/_generated/api";
import { Id } from "../../../../convex/_generated/dataModel";
export function useStoreUserEffect() {
const { isLoading, isAuthenticated } = useConvexAuth();
const { user } = useUser();
const [userId, setUserId] = useState<Id<"users"> | null>(null);
const storeUser = useMutation(api.users.store);
useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
storeUser().then((id) => {
if (!cancelled) setUserId(id);
});
return () => {
cancelled = true;
setUserId(null);
};
}, [isAuthenticated, storeUser, user?.id]);
return {
isLoading: isLoading || (isAuthenticated && userId === null),
isAuthenticated: isAuthenticated && userId !== null,
};
}

View File

@@ -0,0 +1,97 @@
import {
DashboardSquare02Icon,
ShoppingCart01Icon,
PackageIcon,
UserMultipleIcon,
} from "@hugeicons/core-free-icons";
export const ROUTE_LABELS: Record<string, string> = {
orders: "Orders",
products: "Products",
categories: "Categories",
images: "Images",
variants: "Variants",
customers: "Customers",
reviews: "Reviews",
messages: "Messages",
newsletter: "Newsletter",
users: "Users",
settings: "Settings",
returns: "Returns",
};
export const NAV_LINKS = {
overview: [
{
title: "Dashboard",
url: "/",
icon: DashboardSquare02Icon,
},
],
navMain: [
{
title: "Sales",
url: "/orders",
icon: ShoppingCart01Icon,
items: [
{
title: "Orders",
url: "/orders",
},
{
title: "Returns",
url: "/returns",
},
],
},
{
title: "Products",
url: "/products",
icon: PackageIcon,
items: [
{
title: "Categories",
url: "/products/categories",
},
{
title: "Products",
url: "/products",
},
{
title: "Images",
url: "/images",
},
{
title: "Variants",
url: "/variant",
},
],
},
{
title: "Customers",
url: "/customers",
icon: UserMultipleIcon,
items: [
{
title: "Reviews",
url: "/customers/reviews",
},
{
title: "Messages",
url: "/customers/messages",
},
{
title: "Newsletter",
url: "/customers/newsletter",
},
],
},
],
users: [
{
title: "Users",
url: "/users",
icon: UserMultipleIcon,
},
],
};

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

Some files were not shown because too many files have changed in this diff Show More