diff --git a/apps/admin/CLAUDE.md b/apps/admin/CLAUDE.md
new file mode 100644
index 0000000..a15e18f
--- /dev/null
+++ b/apps/admin/CLAUDE.md
@@ -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 `
` 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 `` — use ShadCN `Label`; use `sr-only` if the design hides it visually
+- Data tables include `scope` on `` elements
+- Icon-only buttons always have `aria-label` — e.g. ` `
+- 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
+
+ {isSubmitting && (
+
+
+
+
+ )}
+ {isSubmitting ? "Saving…" : "Save changes"}
+
+ ```
+- 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";
+```
diff --git a/apps/admin/components.json b/apps/admin/components.json
index 5978e30..6de57c2 100644
--- a/apps/admin/components.json
+++ b/apps/admin/components.json
@@ -1,6 +1,6 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
- "style": "radix-nova",
+ "style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
diff --git a/apps/admin/docs/00-admin-dashboard-feature-checklist.md b/apps/admin/docs/00-admin-dashboard-feature-checklist.md
new file mode 100644
index 0000000..de5c92d
--- /dev/null
+++ b/apps/admin/docs/00-admin-dashboard-feature-checklist.md
@@ -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 | `[ ]` BE+UI | Need Convex file storage upload flow → `products.addImage`. Currently `addImage` takes a URL; need `generateUploadUrl` + upload action. |
+| 3.6 | Image gallery management | `[~]` UI | Backend: `addImage`, `deleteImage`, `reorderImages`. Drag-and-drop reorder UI. |
+| 3.7 | Variant management | `[~]` UI | Backend: `addVariant`, `updateVariant`, `deleteVariant`. Inline table or modal for CRUD. |
+| 3.8 | Stock quantity editing | `[~]` UI | Backend: `updateVariant` accepts `stockQuantity`. Inline edit in variant table. |
+| 3.9 | Price and compare-at-price editing | `[~]` UI | Backend: `updateVariant` accepts `price`, `compareAtPrice`. |
+| 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.1–1.6 Authentication & authorization
+ └─ 2.1–2.4 Navigation & layout shell
+
+Phase 2 ─ Product Management (3-4 days)
+ ├─ 3.1–3.4 Product list, create, edit, archive
+ ├─ 3.5–3.6 Image upload & gallery
+ ├─ 3.7–3.9 Variant CRUD, stock, pricing
+ ├─ 3.10–3.11 SEO fields, search
+ └─ 4.1–4.4 Category management
+
+Phase 3 ─ Order Processing — Core (2-3 days)
+ ├─ 5.1–5.3 Order list, detail, status update
+ ├─ 5.4 Admin cancel
+ ├─ 5.13–5.14 Order notes, search
+
+Phase 4 ─ Shipping & Labels (2-3 days)
+ ├─ 5.5–5.6 Create & print labels (Shippo)
+ ├─ 5.7 Track shipments
+ └─ 5.15 Batch label creation
+
+Phase 5 ─ Refunds & Returns (1-2 days)
+ ├─ 5.8–5.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.1–6.3 Customer list, detail, orders
+```
+
+Total estimated MVP effort: **11–17 days** for a senior engineer.
diff --git a/apps/admin/docs/01-admin-auth-implementation-plan.md b/apps/admin/docs/01-admin-auth-implementation-plan.md
new file mode 100644
index 0000000..c8c9495
--- /dev/null
+++ b/apps/admin/docs/01-admin-auth-implementation-plan.md
@@ -0,0 +1,1049 @@
+# Admin Authentication & Authorization — Implementation Plan
+
+**Date:** 2026-03-04
+**Updated:** 2026-03-04 (v3 — review fixes applied)
+**Audience:** Senior software engineers
+**Scope:** `apps/admin` + shared `convex/` backend + separate Clerk instance
+
+---
+
+## 1. Current State
+
+### What exists
+
+| Component | Status | Detail |
+|-----------|--------|--------|
+| `ClerkProvider` + `ConvexClientProvider` | Wired | Root layout wraps `{children}` inside both providers |
+| `clerkMiddleware` with `auth.protect()` | Active | Every route requires Clerk authentication |
+| `useStoreUserEffect` hook | **Dead code** | Exists at `apps/admin/src/hooks/useStoreUserEffect.ts` but never imported |
+| Convex `requireAdmin` helper | Working | All admin-only mutations/queries enforce `role === "admin" \|\| "super_admin"` server-side |
+| Custom sign-in page | Missing | Users are redirected to Clerk's hosted sign-in |
+| Role-based access gating | Missing | Any authenticated Clerk user (including `customer`) can reach the admin UI |
+| Admin user sync | Missing | First sign-in to the admin app may fail Convex calls if the webhook hasn't fired yet |
+| Clerk instance | **Shared** | Both apps use the same Clerk application — customers can sign in on the admin domain |
+
+### Architecture diagram (current)
+
+```
+One Clerk App (shared keys)
+├── Storefront — public signup, customers
+└── Admin — same keys, no access control
+ ↓
+Browser → Clerk Middleware (auth.protect) → Admin App (no role check)
+ ↓
+ Convex Backend (single JWT issuer)
+ ├─ requireAdmin → rejects customers
+ └─ users.store → creates "customer" role
+```
+
+**Problems:**
+1. Any customer can sign in on the admin domain — Clerk treats them as a valid user.
+2. Once past middleware, the UI breaks with "Unauthorized" errors from every Convex query.
+3. No graceful denial or redirect.
+4. No invite-only account creation for staff.
+
+---
+
+## 2. Target Architecture
+
+```
+Clerk App A: "PetLoft Storefront" Clerk App B: "PetLoft Admin"
+├── Public signup: ON ├── Public signup: OFF
+├── Social logins: ON ├── Invite-only
+├── Customers ├── Staff/admins only
+└── apps/storefront uses keys A └── apps/admin uses keys B
+
+ ↓ ↓
+ Storefront JWT (issuer A) Admin JWT (issuer B)
+ ↓ ↓
+ └──────────── Convex Backend ─────────┘
+ auth.config.ts trusts BOTH issuers
+ Same users table, same requireAdmin()
+```
+
+```
+Browser
+ ↓
+Clerk Middleware (auth.protect — only admin Clerk accounts can sign in)
+ ↓
+Admin App Shell
+ ├─ AdminUserSync (calls users.store → ensures Convex record exists)
+ │ └─ users.store reads publicMetadata.role from JWT → creates with correct role
+ ├─ AdminAuthGate (queries users.current → checks role)
+ │ ├─ role is admin/super_admin → render app
+ │ └─ role is customer (edge case) → render "Access Denied"
+ │ └─ user not yet in Convex → render loading state
+ └─ Pages (all nested inside the auth gate)
+ ↓
+Convex Backend (requireAdmin on every admin function — unchanged)
+```
+
+**Three-layer defense:**
+
+| Layer | Where | What it checks | Security role |
+|-------|-------|----------------|---------------|
+| 0. Isolation | Clerk Dashboard | Signup disabled on Admin app; invite-only | **Prevents account creation** — customers cannot create admin accounts |
+| 1. Authentication | Edge middleware | Valid JWT from Admin Clerk instance | Prevents unauthenticated access |
+| 2. Authorization (server) | Convex function handlers | `requireAdmin(ctx)` | **True security boundary** — rejects non-admin users at the data layer |
+| 3. Authorization (client) | React component gate | `users.current` query result | **UX boundary** — prevents broken UI for edge cases |
+
+Layer 0 is new (Clerk isolation). Layer 2 already exists. This plan adds Layers 0, 1 (improved), and 3.
+
+---
+
+## 3. Design Decisions
+
+### D1: Separate Clerk instances (not shared)
+
+The previous plan used a single shared Clerk app. The revised strategy creates a **second, isolated Clerk application** for the admin.
+
+| Aspect | Shared Clerk (old) | Separate Clerk (new) |
+|--------|-------------------|---------------------|
+| Customer signs in on admin? | Yes — then blocked by client gate | **No — no account exists in App B** |
+| Signup on admin domain | Possible (creates customer) | **Impossible — signup disabled** |
+| Account creation for staff | Manual role promotion in Convex dashboard | **Invite-only via Clerk Backend SDK** |
+| JWT cross-contamination | Same issuer, same JWT | **Different issuers, cryptographically isolated** |
+| Convex config | Single provider | Two providers in `auth.config.ts` |
+
+**Decision: Separate instances.** This eliminates the entire class of "customer reaches admin" problems at the infrastructure level. The client-side `AdminAuthGate` becomes a safety net for edge cases, not the primary defense.
+
+### D2: Shared `@repo/convex` package — no changes needed
+
+The `ConvexClientProvider` in `packages/convex/src/provider.tsx` is context-dependent:
+- `NEXT_PUBLIC_CONVEX_URL` — same Convex deployment for both apps (read from each app's `.env.local`).
+- `useAuth` from `@clerk/nextjs` — reads from the nearest `ClerkProvider` in the React tree, which is initialized with that app's Clerk keys.
+
+Each app provides its own Clerk context via its own environment variables. The shared package works without modification.
+
+### D3: Single `users` table for both Clerk apps
+
+Users from both Clerk applications land in the same Convex `users` table, keyed by `externalId` (the Clerk `subject`). Since the two Clerk apps are separate databases, Clerk IDs are globally unique — zero collision risk.
+
+### D4: Role-aware `users.store` via `publicMetadata`
+
+When an admin is invited via Clerk App B, the invitation carries `publicMetadata: { role: "admin" }`. The JWT identity includes this metadata. The `users.store` mutation reads it on first creation:
+- If `publicMetadata.role` is `"admin"` or `"super_admin"` → use that role.
+- Otherwise → default to `"customer"` (storefront behavior unchanged).
+
+This replaces the manual "promote in Convex dashboard" flow.
+
+### D5: Admin webhook — separate endpoint
+
+The Admin Clerk app needs its own webhook endpoint in Convex so user creation/update/deletion events from App B are processed. This webhook uses a separate signing secret (`CLERK_ADMIN_WEBHOOK_SECRET`). The handler calls the same `upsertFromClerk` internal mutation but passes the role from `publicMetadata`.
+
+### D6: Role authority — webhook is authoritative, `users.store` is best-effort
+
+Two paths can create a user record: the Clerk webhook (`upsertFromClerk`) and the client-side sync (`users.store`). Their role-setting behavior is intentionally asymmetric:
+
+| Path | On insert | On update |
+|------|-----------|-----------|
+| `upsertFromClerk` (webhook) | Sets `role` from `publicMetadata` (or defaults to `"customer"`) | **Patches `role` if provided** — webhook is authoritative |
+| `users.store` (client sync) | Sets `role` from JWT `public_metadata` (or defaults to `"customer"`) | **Never changes role** — only patches `name` |
+
+**The webhook is the authoritative source for role.** If both paths race:
+- Webhook fires first (sets `role: "admin"`) → `users.store` finds existing record → skips role. Correct.
+- `users.store` fires first (sets `role: "admin"` from JWT metadata) → webhook fires → patches role from `publicMetadata`. Also correct (idempotent).
+- `users.store` fires first but JWT template is misconfigured (no `public_metadata` claim) → sets `role: "customer"` → webhook fires → patches to `role: "admin"`. Corrected by webhook.
+
+The webhook acts as a self-healing mechanism for JWT template misconfiguration.
+
+---
+
+## 4. Implementation Phases
+
+### Phase 0 — Clerk & Convex Infrastructure Setup
+
+**Goal:** Create the second Clerk application, configure Convex to trust both JWT issuers, and set up the admin webhook.
+
+#### 0.1 Create Clerk App B ("PetLoft Admin")
+
+In the Clerk Dashboard:
+1. Create new application → name it `"PetLoft Admin"`.
+2. Under **User & Authentication** → disable all signup methods (email, social, etc.).
+3. Note the new keys: `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY`.
+4. Note the JWT issuer domain (e.g. `https://admin-clerk.petloft.com`).
+5. **Configure the `convex` JWT template** — Go to **JWT Templates** → edit (or create) the `convex` template → add `"public_metadata": "{{user.public_metadata}}"` to the custom claims. **This is required.** Without it, `users.store` cannot read the invited role from the JWT and will silently default every admin to `role: "customer"` — a confusing failure mode with no error.
+
+#### 0.2 Update `convex/auth.config.ts` — dual provider
+
+```typescript
+import { AuthConfig } from "convex/server";
+
+export default {
+ providers: [
+ {
+ domain: process.env.CLERK_STOREFRONT_JWT_ISSUER_DOMAIN!,
+ applicationID: "convex",
+ },
+ {
+ domain: process.env.CLERK_ADMIN_JWT_ISSUER_DOMAIN!,
+ applicationID: "convex",
+ },
+ ],
+} satisfies AuthConfig;
+```
+
+Convex will accept JWTs from **either** issuer. All existing `requireAdmin` / `getCurrentUser` checks work unchanged — they resolve the user by `externalId` regardless of which Clerk app issued the JWT.
+
+#### 0.3 Set Convex dashboard environment variables
+
+> **Atomic deploy warning:** Step 0.2 renames `CLERK_JWT_ISSUER_DOMAIN` → `CLERK_STOREFRONT_JWT_ISSUER_DOMAIN` in code. If `convex/auth.config.ts` is deployed before the env var is renamed in the Convex dashboard, **all storefront auth will break** — Convex will reject every storefront JWT. These must happen together: set the new env var in the dashboard first, then deploy the code, then remove the old env var.
+
+Rename the existing variable and add the new one:
+
+| Variable | Value | Purpose |
+|----------|-------|---------|
+| `CLERK_STOREFRONT_JWT_ISSUER_DOMAIN` | `https://` | Existing storefront issuer (was `CLERK_JWT_ISSUER_DOMAIN`) |
+| `CLERK_ADMIN_JWT_ISSUER_DOMAIN` | `https://` | New admin issuer |
+| `CLERK_WEBHOOK_SECRET` | `whsec_...` (storefront) | Unchanged |
+| `CLERK_ADMIN_WEBHOOK_SECRET` | `whsec_...` (admin) | New — for admin Clerk webhook |
+| `CLERK_ADMIN_SECRET_KEY` | `sk_...` (admin) | New — for Clerk Backend SDK (invite flow) |
+| `ADMIN_URL` | `https://admin.petloft.com` | New — redirect URL for invite emails |
+
+#### 0.4 Add admin webhook endpoint in `convex/http.ts`
+
+A second webhook route for admin Clerk events. Uses a separate signing secret and passes `publicMetadata.role` to the upsert mutation:
+
+```typescript
+http.route({
+ path: "/clerk-admin-webhook",
+ method: "POST",
+ handler: httpAction(async (ctx, request) => {
+ const event = await validateAdminRequest(request);
+ if (!event) return new Response("Error", { status: 400 });
+
+ switch (event.type) {
+ case "user.created":
+ case "user.updated": {
+ const role = event.data.public_metadata?.role;
+ await ctx.runMutation(internal.users.upsertFromClerk, {
+ externalId: event.data.id,
+ name:
+ `${event.data.first_name ?? ""} ${event.data.last_name ?? ""}`.trim(),
+ email: event.data.email_addresses[0]?.email_address ?? "",
+ avatarUrl: event.data.image_url ?? undefined,
+ role: role === "admin" || role === "super_admin" ? role : undefined,
+ });
+ break;
+ }
+ case "user.deleted":
+ if (event.data.id) {
+ await ctx.runMutation(internal.users.deleteFromClerk, {
+ externalId: event.data.id,
+ });
+ }
+ break;
+ default:
+ console.log("Ignored admin webhook event:", event.type);
+ }
+
+ return new Response(null, { status: 200 });
+ }),
+});
+
+async function validateAdminRequest(
+ req: Request,
+): Promise {
+ const payload = await req.text();
+ const headers = {
+ "svix-id": req.headers.get("svix-id")!,
+ "svix-timestamp": req.headers.get("svix-timestamp")!,
+ "svix-signature": req.headers.get("svix-signature")!,
+ };
+ try {
+ return new Webhook(process.env.CLERK_ADMIN_WEBHOOK_SECRET!).verify(
+ payload,
+ headers,
+ ) as WebhookEvent;
+ } catch {
+ return null;
+ }
+}
+```
+
+#### 0.5 Update `users.upsertFromClerk` to accept optional role
+
+The existing mutation hard-codes `role: "customer"` on creation. Add an optional `role` argument so the admin webhook can pass the role from `publicMetadata`:
+
+```typescript
+export const upsertFromClerk = internalMutation({
+ args: {
+ externalId: v.string(),
+ name: v.string(),
+ email: v.string(),
+ avatarUrl: v.optional(v.string()),
+ role: v.optional(
+ v.union(v.literal("admin"), v.literal("super_admin"))
+ ),
+ },
+ handler: async (ctx, args) => {
+ const existing = await ctx.db
+ .query("users")
+ .withIndex("by_external_id", (q) =>
+ q.eq("externalId", args.externalId),
+ )
+ .unique();
+
+ if (existing) {
+ const patch: Record = {
+ name: args.name,
+ email: args.email,
+ avatarUrl: args.avatarUrl,
+ };
+ if (args.role) patch.role = args.role;
+ await ctx.db.patch(existing._id, patch);
+ } else {
+ await ctx.db.insert("users", {
+ externalId: args.externalId,
+ name: args.name,
+ email: args.email,
+ avatarUrl: args.avatarUrl,
+ role: args.role ?? "customer",
+ });
+ }
+ },
+});
+```
+
+The storefront webhook call-site doesn't pass `role`, so it defaults to `"customer"` — backward compatible.
+
+#### 0.6 Update `users.store` to read `publicMetadata.role` from JWT
+
+The client-side sync hook calls `users.store`. When an invited admin signs in for the first time (before the webhook fires), `users.store` should also create the record with the correct role:
+
+```typescript
+export const store = mutation({
+ args: {},
+ handler: async (ctx) => {
+ const identity = await ctx.auth.getUserIdentity();
+ if (!identity) throw new Error("Unauthenticated");
+
+ const existing = await ctx.db
+ .query("users")
+ .withIndex("by_external_id", (q) =>
+ q.eq("externalId", identity.subject),
+ )
+ .unique();
+
+ if (existing) {
+ if (existing.name !== identity.name) {
+ await ctx.db.patch(existing._id, {
+ name: identity.name ?? existing.name,
+ });
+ }
+ return existing._id;
+ }
+
+ const metadataRole = (identity as any).public_metadata?.role;
+ const role =
+ metadataRole === "admin" || metadataRole === "super_admin"
+ ? metadataRole
+ : "customer";
+
+ return await ctx.db.insert("users", {
+ name: identity.name ?? "Anonymous",
+ email: identity.email ?? "",
+ role,
+ externalId: identity.subject,
+ avatarUrl: identity.pictureUrl ?? undefined,
+ });
+ },
+});
+```
+
+**Prerequisite:** Step 0.1 (item 5) must be completed — the Admin Clerk `convex` JWT template must include `public_metadata` as a custom claim. Without it, `identity.public_metadata` will be `undefined` and all invited admins will silently land with `role: "customer"`.
+
+#### 0.7 Configure admin Clerk webhook in Clerk Dashboard
+
+In Clerk App B dashboard:
+1. Go to **Webhooks** → Add Endpoint.
+2. URL: `/clerk-admin-webhook`.
+3. Events: `user.created`, `user.updated`, `user.deleted`.
+4. Copy the signing secret → set as `CLERK_ADMIN_WEBHOOK_SECRET` in Convex env vars.
+
+#### Files changed
+
+| File | Action |
+|------|--------|
+| `convex/auth.config.ts` | Edit — add second provider |
+| `convex/http.ts` | Edit — add `/clerk-admin-webhook` route |
+| `convex/users.ts` | Edit — add optional `role` arg to `upsertFromClerk`, read `public_metadata` in `store` |
+| Convex dashboard env vars | Add `CLERK_STOREFRONT_JWT_ISSUER_DOMAIN`, `CLERK_ADMIN_JWT_ISSUER_DOMAIN`, `CLERK_ADMIN_WEBHOOK_SECRET`, `CLERK_ADMIN_SECRET_KEY`, `ADMIN_URL`; rename `CLERK_JWT_ISSUER_DOMAIN` → `CLERK_STOREFRONT_JWT_ISSUER_DOMAIN` |
+
+---
+
+### Phase 1 — Admin User Sync
+
+**Goal:** Ensure every authenticated admin user has a Convex user record on sign-in.
+
+#### 1.1 Create `AdminUserSync` component
+
+Create `apps/admin/src/components/auth/AdminUserSync.tsx`:
+
+```typescript
+"use client";
+
+import { useStoreUserEffect } from "@/hooks/useStoreUserEffect";
+
+export function AdminUserSync() {
+ useStoreUserEffect();
+ return null;
+}
+```
+
+This mirrors the storefront's `StoreUserSync` pattern. The hook calls `users.store`, which now reads `publicMetadata.role` from the JWT — invited admins get the correct role on first sync.
+
+#### 1.2 Wire into dashboard layout (see Phase 3 for route group structure)
+
+`AdminUserSync` will be placed inside the `(dashboard)` group layout, not the root layout.
+
+#### Files changed
+
+| File | Action |
+|------|--------|
+| `apps/admin/src/components/auth/AdminUserSync.tsx` | Create |
+
+---
+
+### Phase 2 — Admin Auth Gate
+
+**Goal:** Block unauthorized users from seeing the admin UI. With separate Clerk instances this is primarily a safety net — only users with an Admin Clerk account can reach this point — but it handles edge cases gracefully.
+
+#### 2.1 Create `useAdminAuth` hook
+
+Create `apps/admin/src/hooks/useAdminAuth.ts`:
+
+```typescript
+"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 };
+}
+```
+
+**State machine:**
+
+```
+isLoading=true → { status: "loading" }
+!isAuthenticated → { status: "denied", reason: "not_authenticated" }
+user === undefined → { status: "loading" } // query still resolving
+user === null → { status: "denied", reason: "no_user_record" }
+user.role ∉ admin set → { status: "denied", reason: "not_admin" }
+user.role ∈ admin set → { status: "authorized", role }
+```
+
+#### 2.2 Create `AdminAuthGate` component
+
+Create `apps/admin/src/components/auth/AdminAuthGate.tsx`:
+
+```typescript
+"use client";
+
+import { useAdminAuth } from "@/hooks/useAdminAuth";
+import { useClerk } from "@clerk/nextjs";
+
+export function AdminAuthGate({ children }: { children: React.ReactNode }) {
+ const auth = useAdminAuth();
+ const { signOut } = useClerk();
+
+ if (auth.status === "loading") {
+ return ;
+ }
+
+ if (auth.status === "denied") {
+ return signOut()} />;
+ }
+
+ return <>{children}>;
+}
+```
+
+`LoadingSkeleton` — full-page centered spinner with "Verifying access..." text.
+
+`AccessDenied` — full-page component with:
+- "You don't have permission to access the admin dashboard."
+- A "Sign out" button.
+- A link back to the storefront.
+
+With separate Clerk instances, the `AccessDenied` state should be extremely rare — it only occurs if:
+- A Convex record has `role: "customer"` but an Admin Clerk account somehow exists (misconfiguration).
+- The user record hasn't been created yet (transient — resolves once `AdminUserSync` completes).
+
+#### Files changed
+
+| File | Action |
+|------|--------|
+| `apps/admin/src/hooks/useAdminAuth.ts` | Create |
+| `apps/admin/src/components/auth/AdminAuthGate.tsx` | Create |
+| `apps/admin/src/components/auth/AccessDenied.tsx` | Create |
+| `apps/admin/src/components/auth/LoadingSkeleton.tsx` | Create |
+
+---
+
+### Phase 3 — Custom Sign-In Page + Route Groups
+
+**Goal:** Replace the Clerk hosted sign-in redirect with an in-app sign-in page. Structure routes so the sign-in page bypasses the auth gate.
+
+#### 3.1 Route group structure
+
+```
+apps/admin/src/app/
+├── layout.tsx (root: ClerkProvider + ConvexClientProvider only)
+├── (auth)/
+│ └── sign-in/[[...sign-in]]/
+│ └── page.tsx (public — Clerk , no gate)
+└── (dashboard)/
+ ├── layout.tsx (AdminUserSync + AdminAuthGate + admin shell)
+ └── page.tsx (dashboard home)
+```
+
+The root layout provides only providers. The `(dashboard)` group layout wraps everything in the auth gate. The `(auth)` group has no additional layout — the sign-in page renders without admin chrome.
+
+#### 3.2 Root layout (final shape)
+
+```tsx
+// apps/admin/src/app/layout.tsx
+import type { Metadata } from "next";
+import { Inter } from "next/font/google";
+import { ClerkProvider } from "@clerk/nextjs";
+import { ConvexClientProvider } from "@repo/convex";
+import "./globals.css";
+
+const inter = Inter({ 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 (
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+```
+
+`ClerkProvider` reads `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` from the admin app's `.env.local` — these are the **Admin Clerk (App B)** keys. No code change; the provider is key-driven.
+
+#### 3.3 Dashboard group layout (final shape)
+
+```tsx
+// apps/admin/src/app/(dashboard)/layout.tsx
+import { AdminUserSync } from "@/components/auth/AdminUserSync";
+import { AdminAuthGate } from "@/components/auth/AdminAuthGate";
+import { AdminHeader } from "@/components/layout/AdminHeader";
+
+export default function DashboardLayout({ children }: { children: React.ReactNode }) {
+ return (
+ <>
+
+
+
+ {children}
+
+ >
+ );
+}
+```
+
+`AdminUserSync` is a sibling of `AdminAuthGate`, not nested inside it. The sync mutation fires in parallel with the role query. If the record doesn't exist yet, `users.store` creates it (with role from `publicMetadata`), and `users.current` re-fires reactively via Convex subscription.
+
+#### 3.4 Sign-in page
+
+```tsx
+// apps/admin/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx
+import { SignIn } from "@clerk/nextjs";
+
+export default function SignInPage() {
+ return (
+
+
+
+ );
+}
+```
+
+Since signup is disabled on Admin Clerk, the ` ` component will only show sign-in fields — no "Create account" link.
+
+#### 3.5 Update middleware to exclude sign-in route
+
+```typescript
+// apps/admin/src/middleware.ts
+import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
+
+const isPublicRoute = createRouteMatcher(["/sign-in(.*)"]);
+
+export default clerkMiddleware(async (auth, req) => {
+ if (!isPublicRoute(req)) {
+ await auth.protect();
+ }
+});
+
+export const config = {
+ matcher: [
+ "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
+ "/(api|trpc)(.*)",
+ ],
+};
+```
+
+#### 3.6 Set admin environment variables
+
+Update `apps/admin/.env.local`:
+
+```bash
+NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud
+NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_live_admin_xxxx # App B keys
+CLERK_SECRET_KEY=sk_live_admin_xxxx # App B keys
+NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
+NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/
+NEXT_PUBLIC_APP_URL=http://localhost:3001
+NEXT_PUBLIC_STOREFRONT_URL=http://localhost:3000
+```
+
+#### Files changed
+
+| File | Action |
+|------|--------|
+| `apps/admin/src/app/layout.tsx` | Edit — remove dead imports, providers only |
+| `apps/admin/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx` | Create |
+| `apps/admin/src/app/(dashboard)/layout.tsx` | Create — auth gate + shell |
+| `apps/admin/src/app/(dashboard)/page.tsx` | Move from `apps/admin/src/app/page.tsx` |
+| `apps/admin/src/app/page.tsx` | Delete (moved) |
+| `apps/admin/src/middleware.ts` | Edit — add `createRouteMatcher` exclusion |
+| `apps/admin/.env.local` | Edit — App B Clerk keys + sign-in URL vars |
+| `apps/admin/.env.example` | Edit — document all new vars |
+
+---
+
+### Phase 4 — Admin Layout Shell (Header with UserButton)
+
+**Goal:** Add a persistent header with the Clerk ` ` so admins can manage their session.
+
+#### 4.1 Create admin header
+
+Create `apps/admin/src/components/layout/AdminHeader.tsx`:
+
+A client component that renders:
+- App title / logo ("The Pet Loft — Admin")
+- Clerk ` ` for sign-out and profile management
+
+This is a minimal header. It will be expanded as admin pages are built (sidebar, breadcrumbs, etc.).
+
+#### Files changed
+
+| File | Action |
+|------|--------|
+| `apps/admin/src/components/layout/AdminHeader.tsx` | Create |
+
+---
+
+### Phase 5 — `super_admin` vs `admin` Permission Differentiation
+
+**Goal:** Prepare for granular permissions where `super_admin` can do things `admin` cannot (e.g., invite other admins, change app settings).
+
+#### 5.1 Create `RequireRole` component
+
+```typescript
+"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}>;
+}
+```
+
+#### 5.2 Add `requireSuperAdmin` helper to Convex
+
+Add to `convex/model/users.ts`:
+
+```typescript
+export async function requireSuperAdmin(ctx: QueryCtx) {
+ const user = await getCurrentUserOrThrow(ctx);
+ if (user.role !== "super_admin") {
+ throw new Error("Unauthorized: super_admin access required");
+ }
+ return user;
+}
+```
+
+#### Files changed
+
+| File | Action |
+|------|--------|
+| `apps/admin/src/components/auth/RequireRole.tsx` | Create |
+| `convex/model/users.ts` | Edit — add `requireSuperAdmin` |
+
+---
+
+### Phase 6 — Admin Invitation System
+
+**Goal:** Enable super-admins to invite staff members via the Clerk Backend SDK. This is the only way to create admin accounts since signup is disabled on Clerk App B.
+
+#### 6.1 Add `assertSuperAdmin` internal query
+
+Add to `convex/users.ts`:
+
+```typescript
+export const assertSuperAdmin = internalQuery({
+ args: {},
+ handler: async (ctx) => {
+ return await Users.requireSuperAdmin(ctx);
+ },
+});
+```
+
+This wraps the existing `requireSuperAdmin` helper as an internal query that actions can call via `ctx.runQuery`. It keeps authorization logic centralized in `model/users.ts` rather than duplicating role checks inline.
+
+#### 6.2 Create admin invitation action
+
+Create `convex/adminInvitations.ts`:
+
+```typescript
+"use node";
+
+import { v } from "convex/values";
+import { action } from "./_generated/server";
+import { internal } from "./_generated/api";
+import { createClerkClient } from "@clerk/backend";
+
+export const inviteAdmin = action({
+ args: {
+ email: v.string(),
+ role: v.union(v.literal("admin"), v.literal("super_admin")),
+ },
+ handler: async (ctx, { email, role }) => {
+ await ctx.runQuery(internal.users.assertSuperAdmin);
+
+ const clerk = createClerkClient({
+ secretKey: process.env.CLERK_ADMIN_SECRET_KEY!,
+ });
+
+ await clerk.invitations.createInvitation({
+ emailAddress: email,
+ redirectUrl: `${process.env.ADMIN_URL}/sign-in`,
+ publicMetadata: { role },
+ });
+ },
+});
+```
+
+The action delegates authorization to `assertSuperAdmin` via `ctx.runQuery` — this runs in a transaction with full `ctx.db` access, reusing the centralized `requireSuperAdmin` helper from `model/users.ts`. No inline role-check duplication.
+
+#### 6.3 Admin invitation flow
+
+```
+Super-admin → Admin UI → "Invite Staff Member" form
+ ↓
+ inviteAdmin action (assertSuperAdmin gate)
+ ↓
+ Clerk Backend SDK (App B keys) creates invitation
+ with redirectUrl: ADMIN_URL/sign-in
+ ↓
+ Staff receives email with invitation link
+ ↓
+ Staff clicks link → Clerk invite-only signup (App B)
+ → sets password → redirected to /sign-in
+ ↓
+ Clerk webhook → /clerk-admin-webhook → upsertFromClerk(role: "admin")
+ + users.store reads publicMetadata.role → creates with role: "admin"
+ ↓
+ AdminAuthGate: authorized ✅
+```
+
+#### 6.4 Seed the first super-admin
+
+Before the invite system exists, you need at least one `super_admin` user. Two options:
+
+**Option A (recommended for development):** Manually create a user in Admin Clerk Dashboard → set their `publicMetadata` to `{ "role": "super_admin" }`. When they sign in, `users.store` reads this and creates the Convex record with `role: "super_admin"`.
+
+**Option B:** Create the user record directly in the Convex dashboard with `role: "super_admin"` and the correct `externalId` matching the Admin Clerk user.
+
+#### Files changed
+
+| File | Action |
+|------|--------|
+| `convex/adminInvitations.ts` | Create |
+
+---
+
+## 5. File Inventory
+
+### New files
+
+| File | Phase | Purpose |
+|------|-------|---------|
+| `apps/admin/src/components/auth/AdminUserSync.tsx` | 1 | Calls `useStoreUserEffect` to sync Clerk → Convex |
+| `apps/admin/src/hooks/useAdminAuth.ts` | 2 | Hook returning `loading \| authorized \| denied` state |
+| `apps/admin/src/components/auth/AdminAuthGate.tsx` | 2 | Wraps app; blocks non-admin users |
+| `apps/admin/src/components/auth/AccessDenied.tsx` | 2 | Full-page "no permission" UI with sign-out button |
+| `apps/admin/src/components/auth/LoadingSkeleton.tsx` | 2 | Full-page loading state while auth resolves |
+| `apps/admin/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx` | 3 | Clerk ` ` component page |
+| `apps/admin/src/app/(dashboard)/layout.tsx` | 3 | Dashboard group layout (auth gate + shell) |
+| `apps/admin/src/app/(dashboard)/page.tsx` | 3 | Dashboard home (moved from root) |
+| `apps/admin/src/components/layout/AdminHeader.tsx` | 4 | Header with app title + ` ` |
+| `apps/admin/src/components/auth/RequireRole.tsx` | 5 | Conditional render by role |
+| `convex/adminInvitations.ts` | 6 | Invite staff via Clerk Backend SDK |
+
+### Modified files (Phase 6)
+
+| File | Phase | Change |
+|------|-------|--------|
+| `convex/users.ts` | 6 | Add `assertSuperAdmin` internal query |
+
+### Modified files
+
+| File | Phase | Change |
+|------|-------|--------|
+| `convex/auth.config.ts` | 0 | Add second provider (admin JWT issuer) |
+| `convex/http.ts` | 0 | Add `/clerk-admin-webhook` route |
+| `convex/users.ts` | 0 | `upsertFromClerk`: add optional `role` arg. `store`: read `public_metadata.role` |
+| `apps/admin/src/app/layout.tsx` | 3 | Simplify to providers only |
+| `apps/admin/src/middleware.ts` | 3 | Add `createRouteMatcher` exclusion for `/sign-in` |
+| `apps/admin/.env.local` | 3 | App B Clerk keys + sign-in URL vars |
+| `apps/admin/.env.example` | 3 | Document all new vars |
+| `convex/model/users.ts` | 5 | Add `requireSuperAdmin` helper |
+
+### Deleted files
+
+| File | Phase | Reason |
+|------|-------|--------|
+| `apps/admin/src/app/page.tsx` | 3 | Moved to `(dashboard)/page.tsx` |
+
+---
+
+## 6. Auth Flow Walkthroughs
+
+### Scenario A: Invited admin signs in for the first time
+
+```
+1. Super-admin invites staff@petloft.com via admin UI
+2. Clerk Backend SDK (App B) sends invitation email
+3. Staff clicks link → lands on admin.petloft.com/sign-in
+4. Staff creates password (Clerk invite-only signup)
+5. Clerk redirects to / (NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL)
+6. Middleware: signed in (valid App B JWT) → pass through
+7. (dashboard)/layout.tsx renders:
+ a. AdminUserSync calls users.store
+ - No existing record → reads publicMetadata.role = "admin"
+ - Creates Convex user with role: "admin"
+ b. AdminAuthGate calls users.current:
+ - user === undefined → show LoadingSkeleton (briefly)
+ - user.role === "admin" → render children
+8. Dashboard page renders ✅
+```
+
+### Scenario B: Random person finds admin URL
+
+```
+1. Person visits admin.petloft.com
+2. Middleware: not signed in → redirect to /sign-in
+3. Person sees Clerk — no "Create account" option (signup disabled)
+4. Person cannot proceed — no account exists in Clerk App B
+5. Dead end ✅
+```
+
+### Scenario C: Customer tries to use storefront credentials on admin
+
+```
+1. Customer visits admin.petloft.com/sign-in
+2. Enters their storefront email/password
+3. Clerk App B rejects — those credentials don't exist in App B's database
+4. "Invalid email or password" error
+5. Dead end ✅
+```
+
+### Scenario D: Admin already signed in (session exists)
+
+```
+1. Admin visits admin.petloft.com
+2. Middleware: signed in (valid App B JWT) → pass through
+3. (dashboard)/layout.tsx renders:
+ a. AdminUserSync: user already exists, no-op
+ b. AdminAuthGate: users.current returns admin → render children
+4. Dashboard page renders immediately ✅
+```
+
+### Scenario E: Admin is demoted (role changed in Convex)
+
+```
+1. Admin is using the dashboard
+2. Super-admin changes their role in Convex to "customer"
+3. Convex real-time subscription on users.current fires
+4. useAdminAuth returns { status: "denied", reason: "not_admin" }
+5. AdminAuthGate immediately renders AccessDenied
+6. All open tabs update reactively ✅
+```
+
+---
+
+## 7. Security Analysis
+
+| Attack vector | Mitigation |
+|---------------|------------|
+| Random person finds admin URL | **Signup disabled** on Admin Clerk (App B) — no account can be created |
+| Customer uses storefront credentials | Clerk App B is a **separate user database** — storefront credentials are invalid |
+| Storefront JWT presented to Convex admin function | `auth.config.ts` trusts both issuers, but `requireAdmin` checks the role in the Convex `users` table — customer records have `role: "customer"` → rejected |
+| Direct Convex API call from browser console | Every admin mutation/query calls `requireAdmin(ctx)` — cannot bypass |
+| JWT spoofing | Clerk JWT validation via `auth.config.ts` — Convex verifies issuer domain cryptographically |
+| Role escalation via `users.store` | Reads `publicMetadata.role` only; `publicMetadata` can only be set server-side via Clerk Backend SDK (requires `CLERK_ADMIN_SECRET_KEY`) — not settable by the user |
+| Brute force sign-in | Attacker must know a valid staff email. Clerk rate-limits sign-in attempts. |
+| XSS accessing admin data | Even if `AdminAuthGate` is bypassed via devtools, Convex `requireAdmin` rejects all queries/mutations server-side |
+
+**Defense-in-depth summary:**
+
+```
+Layer 0: Clerk App B — signup disabled, invite-only
+Layer 1: Edge middleware — valid App B JWT required
+Layer 2: Convex requireAdmin — role check on every function
+Layer 3: AdminAuthGate — UX safety net (client-side)
+```
+
+Every layer independently blocks unauthorized access. Storefront credentials are cryptographically useless on the admin app.
+
+---
+
+## 8. Testing Checklist
+
+| # | Test case | Expected result |
+|---|-----------|-----------------|
+| 1 | Unauthenticated user visits `/` | Redirected to `/sign-in` |
+| 2 | Unauthenticated user visits `/sign-in` | Sign-in page renders (no redirect loop, no "Create account" link) |
+| 3 | Random person tries to sign up on admin | Not possible — no signup UI, no signup API |
+| 4 | Customer uses storefront email/password on admin | "Invalid email or password" — credentials don't exist in App B |
+| 5 | Invited admin signs in for the first time | Convex record created with `role: "admin"`, dashboard renders |
+| 6 | Invited super-admin signs in | Convex record created with `role: "super_admin"`, dashboard renders |
+| 7 | Admin already signed in, refreshes page | Dashboard renders immediately |
+| 8 | Admin clicks `UserButton` → Sign out | Redirected to `/sign-in` |
+| 9 | Super-admin invites new staff via admin UI | Invitation email sent; new staff can sign in |
+| 10 | Admin demoted to customer in Convex dashboard | All open tabs reactively show AccessDenied |
+| 11 | Sign-in page has no admin shell chrome | Clean sign-in UI without header/sidebar |
+| 12 | Storefront auth still works after `auth.config.ts` change | Storefront sign-in/sign-up, cart merge, checkout — all pass |
+| 13 | Storefront `users.store` still defaults to `"customer"` | No `publicMetadata.role` in storefront JWT → role: "customer" |
+
+---
+
+## 9. Environment Variables
+
+### Admin app — `apps/admin/.env.local`
+
+| Variable | Value | Purpose |
+|----------|-------|---------|
+| `NEXT_PUBLIC_CONVEX_URL` | `https://your-project.convex.cloud` | Same Convex deployment |
+| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | `pk_..._admin_xxxx` | **App B** publishable key |
+| `CLERK_SECRET_KEY` | `sk_..._admin_xxxx` | **App B** secret key |
+| `NEXT_PUBLIC_CLERK_SIGN_IN_URL` | `/sign-in` | In-app sign-in route |
+| `NEXT_PUBLIC_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL` | `/` | Post-sign-in redirect |
+| `NEXT_PUBLIC_APP_URL` | `http://localhost:3001` | Admin URL |
+| `NEXT_PUBLIC_STOREFRONT_URL` | `http://localhost:3000` | For "Go to storefront" links |
+
+### Storefront app — `apps/storefront/.env.local`
+
+No changes. Keeps using the original Clerk App A keys.
+
+### Convex dashboard environment variables
+
+| Variable | Value | Purpose |
+|----------|-------|---------|
+| `CLERK_STOREFRONT_JWT_ISSUER_DOMAIN` | `https://` | Renamed from `CLERK_JWT_ISSUER_DOMAIN` |
+| `CLERK_ADMIN_JWT_ISSUER_DOMAIN` | `https://` | New — admin Clerk JWT issuer |
+| `CLERK_WEBHOOK_SECRET` | `whsec_...` (storefront) | Unchanged |
+| `CLERK_ADMIN_WEBHOOK_SECRET` | `whsec_...` (admin) | New — admin Clerk webhook signing secret |
+| `CLERK_ADMIN_SECRET_KEY` | `sk_..._admin_xxxx` | New — for Clerk Backend SDK (invite flow) |
+| `ADMIN_URL` | `https://admin.petloft.com` | New — redirect URL for invite emails |
+| `STRIPE_SECRET_KEY` | Unchanged | — |
+| `STRIPE_WEBHOOK_SECRET` | Unchanged | — |
+| `SHIPPO_API_KEY` | Unchanged | — |
+
+---
+
+## 10. Dependency Audit
+
+| Dependency | Already installed | Used for | Notes |
+|------------|-------------------|----------|-------|
+| `@clerk/nextjs` | Yes (`apps/admin`) | `ClerkProvider`, `SignIn`, `UserButton`, `clerkMiddleware`, `createRouteMatcher` | No version change |
+| `convex/react` | Yes (via `@repo/convex`) | `useConvexAuth`, `useQuery`, `useMutation` | No version change |
+| `@repo/convex` | Yes | `ConvexClientProvider` — shared, context-dependent | **No changes to package** |
+| `@clerk/backend` | Yes (`convex/`) | Already imported in `convex/http.ts` for `WebhookEvent` type. Phase 6 uses `createClerkClient` for invitations. | No new install needed |
+
+---
+
+## 11. Implementation Order & Effort Estimates
+
+| Phase | Description | Effort | Dependencies |
+|-------|-------------|--------|--------------|
+| **0** | Clerk App B setup + Convex dual-provider + admin webhook + role-aware `users.store` | ~1.5 hr | None |
+| **1** | Admin user sync component | ~15 min | Phase 0 |
+| **2** | Admin auth gate + hook + denial page | ~1 hr | Phase 1 |
+| **3** | Custom sign-in page + middleware + route groups | ~45 min | Phase 2 |
+| **4** | Admin layout shell (header) | ~30 min | Phase 3 |
+| **5** | `RequireRole` + `requireSuperAdmin` | ~20 min | Phase 2 |
+| **6** | Admin invitation system | ~1 hr | Phase 5 |
+
+Phases 0–4 are sequential. Phases 5 and 6 can be deferred but are needed before launch.
+
+Total estimated effort: **~5 hours** for a senior engineer.
+
+---
+
+## 12. Seeding the First Super-Admin
+
+This is a one-time bootstrap step required before the invite system (Phase 6) works.
+
+1. Create a user in Admin Clerk Dashboard (App B) manually.
+2. In Clerk Dashboard, edit that user → set **Public Metadata** to `{ "role": "super_admin" }`.
+3. Sign in to the admin app with that account.
+4. `users.store` reads `publicMetadata.role = "super_admin"` → creates Convex record with `role: "super_admin"`.
+5. `AdminAuthGate` confirms role → dashboard renders.
+6. This user can now use the invite system (Phase 6) to onboard other admins.
+
+---
+
+## 13. Future Considerations
+
+| Item | Notes |
+|------|-------|
+| **Same-email policy** | Decide whether admin staff can use the same email as their storefront customer account. Clerk treats them as separate users (separate databases). Either works; enforce domain restriction in `inviteAdmin` if desired. |
+| **Audit logging** | Log admin actions (who changed what, when) in a Convex `auditLogs` table. |
+| **Session timeout** | Consider reducing session lifetime for admin users in Admin Clerk Dashboard → Sessions. |
+| **Multi-tab sync** | Convex real-time subscriptions ensure role changes propagate across tabs automatically. If an admin is demoted, all open tabs reactively show AccessDenied. |
+| **Admin user management page** | A super-admin page to view staff, revoke access (delete from Clerk App B via Backend SDK), and re-invite. |
diff --git a/apps/admin/docs/02-navigation-layout-implementation-plan.md b/apps/admin/docs/02-navigation-layout-implementation-plan.md
new file mode 100644
index 0000000..cf00b59
--- /dev/null
+++ b/apps/admin/docs/02-navigation-layout-implementation-plan.md
@@ -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
+ ├──
+ │ ├── SidebarTrigger ← hamburger on mobile, collapse toggle on desktop
+ │ └── Separator
+ │ └── [BREADCRUMB SLOT — empty]
+ └── {children}
+```
+
+---
+
+## 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
+ ├──
+ │ ├── SidebarTrigger
+ │ ├── Separator
+ │ └── DynamicBreadcrumb ← NEW: reads pathname → renders ShadCN Breadcrumb
+ └── {children}
+```
+
+---
+
+## 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 `` context pattern in a future iteration
+
+**Home segment:** A ` ` 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();
+// ...
+
+```
+
+**Collapsible group items:**
+```tsx
+const isGroupActive = pathname.startsWith(item.url);
+// defaultOpen driven by isGroupActive
+
+
+ // ...
+ {item.items?.map(subItem => (
+
+ ))}
+```
+
+**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 = {
+ 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 ` ` immediately after:
+
+```tsx
+
+```
+
+#### 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 ` ` 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 ` ` 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.
diff --git a/apps/admin/docs/03-products-feature-implementation-plan.md b/apps/admin/docs/03-products-feature-implementation-plan.md
new file mode 100644
index 0000000..4e07ff8
--- /dev/null
+++ b/apps/admin/docs/03-products-feature-implementation-plan.md
@@ -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.1–3.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())` | 1–2 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 doesn’t 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 ` ` — the `Button` component's TypeScript type does not expose the `render` / `nativeButton` props from `@base-ui/react`
diff --git a/apps/admin/package.json b/apps/admin/package.json
index 377431e..5352de9 100644
--- a/apps/admin/package.json
+++ b/apps/admin/package.json
@@ -12,6 +12,7 @@
"dependencies": {
"@base-ui/react": "^1.2.0",
"@clerk/nextjs": "^6.38.2",
+ "@hookform/resolvers": "^5.2.2",
"@hugeicons/core-free-icons": "^3.3.0",
"@hugeicons/react": "^1.1.5",
"@repo/convex": "*",
@@ -21,7 +22,9 @@
"clsx": "^2.1.1",
"lucide-react": "^0.400.0",
"radix-ui": "^1.4.3",
- "tailwind-merge": "^2.6.1"
+ "react-hook-form": "^7.71.2",
+ "tailwind-merge": "^2.6.1",
+ "zod": "^3.25.76"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.0",
@@ -30,4 +33,4 @@
"tailwindcss": "^4.2.0",
"tw-animate-css": "^1.4.0"
}
-}
\ No newline at end of file
+}
diff --git a/apps/admin/src/app/(dashboard)/products/[id]/edit/page.tsx b/apps/admin/src/app/(dashboard)/products/[id]/edit/page.tsx
new file mode 100644
index 0000000..08affc5
--- /dev/null
+++ b/apps/admin/src/app/(dashboard)/products/[id]/edit/page.tsx
@@ -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(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 (
+
+
+
+
+
+
+
+ )
+ }
+
+ // Not found
+ if (product === null) {
+ return (
+
+
+
+
+ Products
+
+
Product not found
+
+
+ This product does not exist or you do not have permission to view it.
+
+
+ )
+ }
+
+ const defaultValues: Partial = {
+ 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 (
+
+
+
+
+
+ Products
+
+
{product.name}
+
+
+ {product.status !== "archived" && (
+
setArchiveOpen(true)}
+ >
+
+ Archive
+
+ )}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+
+ Archive “{product.name}”?
+
+
+ This product will no longer appear on the storefront. You can
+ restore it by editing the product status later.
+
+
+
+ Cancel
+
+ {isArchiving ? "Archiving…" : "Archive"}
+
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/app/(dashboard)/products/new/page.tsx b/apps/admin/src/app/(dashboard)/products/new/page.tsx
new file mode 100644
index 0000000..c52eeb0
--- /dev/null
+++ b/apps/admin/src/app/(dashboard)/products/new/page.tsx
@@ -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(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 (
+
+
+
+
+ Products
+
+
New product
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/admin/src/app/(dashboard)/products/page.tsx b/apps/admin/src/app/(dashboard)/products/page.tsx
index 2a7cc0c..7d9584b 100644
--- a/apps/admin/src/app/(dashboard)/products/page.tsx
+++ b/apps/admin/src/app/(dashboard)/products/page.tsx
@@ -1,5 +1,471 @@
-import StillBuildingPlaceholder from "../../../components/shared/still_building_placeholder";
+"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 = {
+ 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 (
+ onSort(field)}
+ >
+ {children}
+
+
+ )
+}
+
+function TableSkeleton({
+ visibleCols,
+}: {
+ visibleCols: Record
+}) {
+ return (
+ <>
+ {Array.from({ length: 10 }).map((_, i) => (
+
+
+
+
+ {visibleCols.brand && (
+
+
+
+ )}
+ {visibleCols.childCategory && (
+
+
+
+ )}
+ {visibleCols.status && (
+
+
+
+ )}
+ {visibleCols.slug && (
+
+
+
+ )}
+ {visibleCols.tags && (
+
+
+
+ )}
+ {visibleCols.updated && (
+
+
+
+ )}
+
+
+
+
+ ))}
+ >
+ )
+}
+
+// ─── Page ─────────────────────────────────────────────────────────────────────
export default function ProductsPage() {
- return ;
+ const [searchInput, setSearchInput] = useState("")
+ const [searchQuery, setSearchQuery] = useState("")
+ const [visibleCols, setVisibleCols] =
+ useState>(DEFAULT_VISIBLE)
+ const [sortField, setSortField] = useState(null)
+ const [sortOrder, setSortOrder] = useState("asc")
+ const [previewProduct, setPreviewProduct] = useState(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 (
+
+ {/* Header */}
+
+
Products
+
+
+ New product
+
+
+
+ {/* Toolbar */}
+
+
+
+ setSearchInput(e.target.value)}
+ className="pl-8 pr-8"
+ />
+ {searchInput && (
+ setSearchInput("")}
+ aria-label="Clear search"
+ >
+
+
+ )}
+
+
+
+ }>
+ Columns
+
+
+
+ Toggle columns
+
+ {OPTIONAL_COLUMNS.map(({ key, label }) => (
+
+ toggleColumn(key, checked)
+ }
+ >
+ {label}
+
+ ))}
+
+
+
+
+
+ {/* Table */}
+
+
+
+
+
+
+ Name
+
+
+ {visibleCols.brand && (
+
+
+ Brand
+
+
+ )}
+ {visibleCols.childCategory && (
+
+
+ Category
+
+
+ )}
+ {visibleCols.status && (
+ Status
+ )}
+ {visibleCols.slug && Slug }
+ {visibleCols.tags && Tags }
+ {visibleCols.updated && (
+ Updated
+ )}
+
+
+
+
+ {isLoading ? (
+
+ ) : products.length === 0 ? (
+
+
+ {isSearching
+ ? `No products match "${searchQuery}".`
+ : "No products yet. Create your first product to get started."}
+
+
+ ) : (
+ products.map((product: any) => {
+ const statusCfg =
+ STATUS_CONFIG[product.status as keyof typeof STATUS_CONFIG]
+ return (
+
+
+ openPreview(product)}
+ >
+ {product.name}
+
+
+ {visibleCols.brand && (
+
+ {product.brand ?? "—"}
+
+ )}
+ {visibleCols.childCategory && (
+
+ {product.childCategorySlug ?? "—"}
+
+ )}
+ {visibleCols.status && (
+
+ {statusCfg ? (
+
+ {statusCfg.label}
+
+ ) : (
+
+ {product.status}
+
+ )}
+
+ )}
+ {visibleCols.slug && (
+
+ {product.slug}
+
+ )}
+ {visibleCols.tags && (
+
+ {product.tags?.length > 0
+ ? product.tags.join(", ")
+ : "—"}
+
+ )}
+ {visibleCols.updated && (
+
+ {product.updatedAt
+ ? new Date(product.updatedAt).toLocaleDateString()
+ : "—"}
+
+ )}
+
+ }
+ productName={product.name}
+ isArchived={product.status === "archived"}
+ />
+
+
+ )
+ })
+ )}
+
+
+
+
+ {/* Pagination footer — list mode only */}
+ {!isSearching && (
+
+
+ {listStatus === "Exhausted"
+ ? `${listResults.length} product${listResults.length !== 1 ? "s" : ""} total`
+ : `${listResults.length} loaded`}
+
+ {listStatus === "CanLoadMore" && (
+ loadMore(25)}>
+ Load more
+
+ )}
+
+ )}
+
+
+
+ )
}
diff --git a/apps/admin/src/components/layout/DynamicBreadcrumb.tsx b/apps/admin/src/components/layout/DynamicBreadcrumb.tsx
index c5e25a6..62b1944 100644
--- a/apps/admin/src/components/layout/DynamicBreadcrumb.tsx
+++ b/apps/admin/src/components/layout/DynamicBreadcrumb.tsx
@@ -36,10 +36,8 @@ export function DynamicBreadcrumb() {
{/* Home icon link */}
-
-
-
-
+ }>
+
@@ -55,8 +53,8 @@ export function DynamicBreadcrumb() {
{isLast ? (
{label}
) : (
-
- {label}
+ }>
+ {label}
)}
diff --git a/apps/admin/src/components/layout/sidebar/app-sidebar.tsx b/apps/admin/src/components/layout/sidebar/app-sidebar.tsx
index 5b681f6..38198b6 100644
--- a/apps/admin/src/components/layout/sidebar/app-sidebar.tsx
+++ b/apps/admin/src/components/layout/sidebar/app-sidebar.tsx
@@ -24,7 +24,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
-
+
diff --git a/apps/admin/src/components/layout/sidebar/nav-main.tsx b/apps/admin/src/components/layout/sidebar/nav-main.tsx
index 6a4d083..dde2472 100644
--- a/apps/admin/src/components/layout/sidebar/nav-main.tsx
+++ b/apps/admin/src/components/layout/sidebar/nav-main.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
@@ -16,26 +17,64 @@ import {
} 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 (
+
+
+
+ }
+ >
+
+ {item.title}
+
+
+
+
+ {item.items?.map((subItem) => (
+
+ }
+ isActive={pathname === subItem.url}
+ >
+ {subItem.title}
+
+
+ ))}
+
+
+
+
+ );
+}
+
export function NavMain({
overview,
isOverview,
navMain,
}: {
- overview: {
- title: string;
- url: string;
- icon: IconSvgElement;
- }[];
+ overview: Omit[];
isOverview?: boolean;
- navMain: {
- title: string;
- url: string;
- icon: IconSvgElement;
- items?: {
- title: string;
- url: string;
- }[];
- }[];
+ navMain: NavItem[];
}) {
const pathname = usePathname();
@@ -46,11 +85,13 @@ export function NavMain({
{overview.map((item) => (
-
-
-
- {item.title}
-
+ }
+ tooltip={item.title}
+ isActive={pathname === item.url}
+ >
+
+ {item.title}
))}
@@ -63,40 +104,9 @@ export function NavMain({
Application
- {navMain.map((item) => {
- const isGroupActive = pathname.startsWith(item.url);
- return (
-
-
-
-
-
- {item.title}
-
-
-
-
-
- {item.items?.map((subItem) => (
-
-
-
- {subItem.title}
-
-
-
- ))}
-
-
-
-
- );
- })}
+ {navMain.map((item) => (
+
+ ))}
);
diff --git a/apps/admin/src/components/products/ProductActionsMenu.tsx b/apps/admin/src/components/products/ProductActionsMenu.tsx
new file mode 100644
index 0000000..9231984
--- /dev/null
+++ b/apps/admin/src/components/products/ProductActionsMenu.tsx
@@ -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 (
+ <>
+
+
+ }
+ >
+
+
+
+ }>
+
+ Edit
+
+ {!isArchived && (
+ <>
+
+ setArchiveOpen(true)}
+ >
+
+ Archive
+
+ >
+ )}
+
+
+
+
+
+
+ Archive “{productName}”?
+
+ This product will no longer appear on the storefront. You can
+ restore it by editing the product later.
+
+
+
+ Cancel
+
+ {isArchiving ? "Archiving…" : "Archive"}
+
+
+
+
+ >
+ )
+}
diff --git a/apps/admin/src/components/products/ProductForm.tsx b/apps/admin/src/components/products/ProductForm.tsx
new file mode 100644
index 0000000..113e098
--- /dev/null
+++ b/apps/admin/src/components/products/ProductForm.tsx
@@ -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
+
+// ─── Types ─────────────────────────────────────────────────────────────────────
+
+export type CategoryOption = {
+ _id: string
+ name: string
+ slug: string
+ parentId?: string
+}
+
+interface ProductFormProps {
+ mode: "create" | "edit"
+ categories: CategoryOption[]
+ defaultValues?: Partial
+ onSubmit: (values: ProductFormValues) => Promise
+ 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({
+ 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 = {}
+ 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 (
+
+
+ )
+}
diff --git a/apps/admin/src/components/products/ProductPreviewDialog.tsx b/apps/admin/src/components/products/ProductPreviewDialog.tsx
new file mode 100644
index 0000000..2c62fee
--- /dev/null
+++ b/apps/admin/src/components/products/ProductPreviewDialog.tsx
@@ -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 (
+
+ {label}
+ {children}
+
+ )
+}
+
+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 (
+
+
+
+ {product.name}
+
+
+
+
+ {/* Core fields */}
+
+
+ {statusCfg.label}
+
+ {product.slug}
+ {product.brand}
+
+
+
+
+ {/* Category */}
+
+
+ Category
+
+
{product.parentCategorySlug}
+
{product.childCategorySlug}
+
{product.topCategorySlug}
+
+
+ {(product.shortDescription || product.description) && (
+ <>
+
+
+
+ Description
+
+
{product.shortDescription}
+ {product.description && (
+
+
Full
+
+ {product.description}
+
+
+ )}
+
+ >
+ )}
+
+ {product.tags.length > 0 && (
+ <>
+
+
+
+ {product.tags.map((t) => (
+
+ {t}
+
+ ))}
+
+
+ >
+ )}
+
+ {hasAttrs && (
+ <>
+
+
+
+ Attributes
+
+ {(attrs?.petSize?.length ?? 0) > 0 && (
+
{attrs!.petSize!.join(", ")}
+ )}
+ {(attrs?.ageRange?.length ?? 0) > 0 && (
+
{attrs!.ageRange!.join(", ")}
+ )}
+ {(attrs?.specialDiet?.length ?? 0) > 0 && (
+
+ {attrs!.specialDiet!.join(", ")}
+
+ )}
+ {attrs?.material && (
+
{attrs.material}
+ )}
+ {attrs?.flavor && (
+
{attrs.flavor}
+ )}
+
+ >
+ )}
+
+ {hasSeo && (
+ <>
+
+
+
+ SEO
+
+
{product.seoTitle}
+
{product.seoDescription}
+
{product.canonicalSlug}
+
+ >
+ )}
+
+
+
+ {/* Meta */}
+
+
+ Meta
+
+
{formatDate(product.createdAt)}
+
{formatDate(product.updatedAt)}
+ {product.averageRating != null && (
+
+ {product.averageRating.toFixed(1)} ({product.reviewCount ?? 0}{" "}
+ reviews)
+
+ )}
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/components/ui/alert-dialog.tsx b/apps/admin/src/components/ui/alert-dialog.tsx
new file mode 100644
index 0000000..2dcee83
--- /dev/null
+++ b/apps/admin/src/components/ui/alert-dialog.tsx
@@ -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
+}
+
+function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: AlertDialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function AlertDialogContent({
+ className,
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Popup.Props & {
+ size?: "default" | "sm"
+}) {
+ return (
+
+
+
+
+ )
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogMedia({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function AlertDialogCancel({
+ className,
+ variant = "outline",
+ size = "default",
+ ...props
+}: AlertDialogPrimitive.Close.Props &
+ Pick, "variant" | "size">) {
+ return (
+ }
+ {...props}
+ />
+ )
+}
+
+export {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogMedia,
+ AlertDialogOverlay,
+ AlertDialogPortal,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+}
diff --git a/apps/admin/src/components/ui/avatar.tsx b/apps/admin/src/components/ui/avatar.tsx
index ea65850..e4fed86 100644
--- a/apps/admin/src/components/ui/avatar.tsx
+++ b/apps/admin/src/components/ui/avatar.tsx
@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
-import { Avatar as AvatarPrimitive } from "radix-ui"
+import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
@@ -9,7 +9,7 @@ function Avatar({
className,
size = "default",
...props
-}: React.ComponentProps & {
+}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
@@ -17,7 +17,7 @@ function Avatar({
data-slot="avatar"
data-size={size}
className={cn(
- "group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
+ "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}
@@ -25,14 +25,14 @@ function Avatar({
)
}
-function AvatarImage({
- className,
- ...props
-}: React.ComponentProps) {
+function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
)
@@ -41,7 +41,7 @@ function AvatarImage({
function AvatarFallback({
className,
...props
-}: React.ComponentProps) {
+}: AvatarPrimitive.Fallback.Props) {
return (
) {
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",
@@ -103,7 +103,7 @@ export {
Avatar,
AvatarImage,
AvatarFallback,
- AvatarBadge,
AvatarGroup,
AvatarGroupCount,
+ AvatarBadge,
}
diff --git a/apps/admin/src/components/ui/badge.tsx b/apps/admin/src/components/ui/badge.tsx
new file mode 100644
index 0000000..b20959d
--- /dev/null
+++ b/apps/admin/src/components/ui/badge.tsx
@@ -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) {
+ return useRender({
+ defaultTagName: "span",
+ props: mergeProps<"span">(
+ {
+ className: cn(badgeVariants({ variant }), className),
+ },
+ props
+ ),
+ render,
+ state: {
+ slot: "badge",
+ variant,
+ },
+ })
+}
+
+export { Badge, badgeVariants }
diff --git a/apps/admin/src/components/ui/breadcrumb.tsx b/apps/admin/src/components/ui/breadcrumb.tsx
index 004bb63..29d7908 100644
--- a/apps/admin/src/components/ui/breadcrumb.tsx
+++ b/apps/admin/src/components/ui/breadcrumb.tsx
@@ -1,11 +1,20 @@
import * as React from "react"
-import { ChevronRight, MoreHorizontal } from "lucide-react"
-import { Slot } from "radix-ui"
+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({ ...props }: React.ComponentProps<"nav">) {
- return
+function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
+ return (
+
+ )
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
@@ -13,7 +22,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
) {
return (
)
}
function BreadcrumbLink({
- asChild,
className,
+ render,
...props
-}: React.ComponentProps<"a"> & {
- asChild?: boolean
-}) {
- const Comp = asChild ? Slot.Root : "a"
-
- return (
-
- )
+}: 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">) {
@@ -75,7 +86,9 @@ function BreadcrumbSeparator({
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
- {children ?? }
+ {children ?? (
+
+ )}
)
}
@@ -89,10 +102,13 @@ function BreadcrumbEllipsis({
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
- className={cn("flex size-9 items-center justify-center", className)}
+ className={cn(
+ "flex size-5 items-center justify-center [&>svg]:size-4",
+ className
+ )}
{...props}
>
-
+
More
)
diff --git a/apps/admin/src/components/ui/button.tsx b/apps/admin/src/components/ui/button.tsx
index 4d38506..9710b0a 100644
--- a/apps/admin/src/components/ui/button.tsx
+++ b/apps/admin/src/components/ui/button.tsx
@@ -1,34 +1,38 @@
-import * as React from "react"
+"use client"
+
+import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
-import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
- "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+ "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 hover:bg-primary/90",
- destructive:
- "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
- "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+ "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",
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
- "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ "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-9 px-4 py-2 has-[>svg]:px-3",
- xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
- sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
- lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
- icon: "size-9",
- "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
- "icon-sm": "size-8",
- "icon-lg": "size-10",
+ 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: {
@@ -42,19 +46,11 @@ function Button({
className,
variant = "default",
size = "default",
- asChild = false,
...props
-}: React.ComponentProps<"button"> &
- VariantProps & {
- asChild?: boolean
- }) {
- const Comp = asChild ? Slot.Root : "button"
-
+}: ButtonPrimitive.Props & VariantProps) {
return (
-
diff --git a/apps/admin/src/components/ui/checkbox.tsx b/apps/admin/src/components/ui/checkbox.tsx
new file mode 100644
index 0000000..136418f
--- /dev/null
+++ b/apps/admin/src/components/ui/checkbox.tsx
@@ -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 (
+
+
+
+
+
+ )
+}
+
+export { Checkbox }
diff --git a/apps/admin/src/components/ui/collapsible.tsx b/apps/admin/src/components/ui/collapsible.tsx
index 2f7a4e7..488fb33 100644
--- a/apps/admin/src/components/ui/collapsible.tsx
+++ b/apps/admin/src/components/ui/collapsible.tsx
@@ -1,32 +1,20 @@
"use client"
-import { Collapsible as CollapsiblePrimitive } from "radix-ui"
+import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
-function Collapsible({
- ...props
-}: React.ComponentProps) {
+function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
return
}
-function CollapsibleTrigger({
- ...props
-}: React.ComponentProps) {
+function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
return (
-
+
)
}
-function CollapsibleContent({
- ...props
-}: React.ComponentProps) {
+function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
return (
-
+
)
}
diff --git a/apps/admin/src/components/ui/dialog.tsx b/apps/admin/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..0a512c9
--- /dev/null
+++ b/apps/admin/src/components/ui/dialog.tsx
@@ -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
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/apps/admin/src/components/ui/dropdown-menu.tsx b/apps/admin/src/components/ui/dropdown-menu.tsx
index ae1fcf6..2488966 100644
--- a/apps/admin/src/components/ui/dropdown-menu.tsx
+++ b/apps/admin/src/components/ui/dropdown-menu.tsx
@@ -1,61 +1,76 @@
"use client"
import * as React from "react"
-import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
-import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
+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
-}: React.ComponentProps) {
- return
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+ return
}
-function DropdownMenuPortal({
- ...props
-}: React.ComponentProps) {
- return (
-
- )
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+ return
}
-function DropdownMenuTrigger({
- ...props
-}: React.ComponentProps) {
- return (
-
- )
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+ return
}
function DropdownMenuContent({
- className,
+ align = "start",
+ alignOffset = 0,
+ side = "bottom",
sideOffset = 4,
+ className,
...props
-}: React.ComponentProps) {
+}: MenuPrimitive.Popup.Props &
+ Pick<
+ MenuPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
return (
-
-
+
-
+ >
+
+
+
)
}
-function DropdownMenuGroup({
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+ return
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
...props
-}: React.ComponentProps) {
+}: MenuPrimitive.GroupLabel.Props & {
+ inset?: boolean
+}) {
return (
-
+
)
}
@@ -64,17 +79,17 @@ function DropdownMenuItem({
inset,
variant = "default",
...props
-}: React.ComponentProps & {
+}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
-
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ align = "start",
+ alignOffset = -3,
+ side = "right",
+ sideOffset = 0,
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
function DropdownMenuCheckboxItem({
className,
children,
checked,
+ inset,
...props
-}: React.ComponentProps) {
+}: MenuPrimitive.CheckboxItem.Props & {
+ inset?: boolean
+}) {
return (
-
-
-
-
-
+
+
+
+
{children}
-
+
)
}
-function DropdownMenuRadioGroup({
- ...props
-}: React.ComponentProps) {
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
-
@@ -122,53 +194,40 @@ function DropdownMenuRadioGroup({
function DropdownMenuRadioItem({
className,
children,
+ inset,
...props
-}: React.ComponentProps) {
+}: MenuPrimitive.RadioItem.Props & {
+ inset?: boolean
+}) {
return (
-
-
-
-
-
+
+
+
+
{children}
-
- )
-}
-
-function DropdownMenuLabel({
- className,
- inset,
- ...props
-}: React.ComponentProps & {
- inset?: boolean
-}) {
- return (
-
+
)
}
function DropdownMenuSeparator({
className,
...props
-}: React.ComponentProps) {
+}: MenuPrimitive.Separator.Props) {
return (
-
- )
-}
-
-function DropdownMenuSub({
- ...props
-}: React.ComponentProps) {
- return
-}
-
-function DropdownMenuSubTrigger({
- className,
- inset,
- children,
- ...props
-}: React.ComponentProps & {
- inset?: boolean
-}) {
- return (
-
- {children}
-
-
- )
-}
-
-function DropdownMenuSubContent({
- className,
- ...props
-}: React.ComponentProps) {
- return (
- = FieldPath,
+> = { name: TName }
+
+const FormFieldContext = React.createContext(
+ {} as FormFieldContextValue,
+)
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>({
+ ...props
+}: ControllerProps) => (
+
+
+
+)
+
+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 ")
+ }
+
+ 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(
+ {} as FormItemContextValue,
+)
+
+function FormItem({ className, ...props }: React.ComponentProps<"div">) {
+ const id = React.useId()
+ return (
+
+
+
+ )
+}
+
+function FormLabel({ className, ...props }: React.ComponentProps<"label">) {
+ const { error, formItemId } = useFormField()
+ return (
+
+ )
+}
+
+function FormControl({ children }: { children: React.ReactElement> }) {
+ 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 (
+
+ )
+}
+
+function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
+ const { error, formMessageId } = useFormField()
+ const body = error ? String(error?.message ?? "") : props.children
+ if (!body) return null
+ return (
+
+ {body}
+
+ )
+}
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+}
diff --git a/apps/admin/src/components/ui/input.tsx b/apps/admin/src/components/ui/input.tsx
index f1124ae..7d21bab 100644
--- a/apps/admin/src/components/ui/input.tsx
+++ b/apps/admin/src/components/ui/input.tsx
@@ -1,16 +1,15 @@
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 (
- ) {
+ return (
+
+ )
+}
+
+export { Label }
diff --git a/apps/admin/src/components/ui/scroll-area.tsx b/apps/admin/src/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..84c1e9f
--- /dev/null
+++ b/apps/admin/src/components/ui/scroll-area.tsx
@@ -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 (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: ScrollAreaPrimitive.Scrollbar.Props) {
+ return (
+
+
+
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/apps/admin/src/components/ui/select.tsx b/apps/admin/src/components/ui/select.tsx
new file mode 100644
index 0000000..8946b1c
--- /dev/null
+++ b/apps/admin/src/components/ui/select.tsx
@@ -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 (
+
+ )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+ }
+ />
+
+ )
+}
+
+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 (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+
+
+ {children}
+
+
+ }
+ >
+
+
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/apps/admin/src/components/ui/separator.tsx b/apps/admin/src/components/ui/separator.tsx
index cd873e3..6e1369e 100644
--- a/apps/admin/src/components/ui/separator.tsx
+++ b/apps/admin/src/components/ui/separator.tsx
@@ -1,23 +1,20 @@
"use client"
-import * as React from "react"
-import { Separator as SeparatorPrimitive } from "radix-ui"
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
- decorative = true,
...props
-}: React.ComponentProps) {
+}: SeparatorPrimitive.Props) {
return (
- ) {
+function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return
}
-function SheetTrigger({
- ...props
-}: React.ComponentProps) {
+function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return
}
-function SheetClose({
- ...props
-}: React.ComponentProps) {
+function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return
}
-function SheetPortal({
- ...props
-}: React.ComponentProps) {
+function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return
}
-function SheetOverlay({
- className,
- ...props
-}: React.ComponentProps) {
+function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
- & {
+}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
-
{children}
{showCloseButton && (
-
-
+
+ }
+ >
+
Close
)}
-
+
)
}
@@ -89,7 +84,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
)
@@ -105,14 +100,11 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
)
}
-function SheetTitle({
- className,
- ...props
-}: React.ComponentProps) {
+function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
return (
)
@@ -121,7 +113,7 @@ function SheetTitle({
function SheetDescription({
className,
...props
-}: React.ComponentProps) {
+}: SheetPrimitive.Description.Props) {
return (
-
-
- {children}
-
-
+
+ {children}
+
)
}
@@ -157,6 +156,7 @@ function Sidebar({
collapsible = "offcanvas",
className,
children,
+ dir,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
@@ -184,6 +184,7 @@ function Sidebar({
return (
{children}
@@ -265,15 +264,15 @@ function SidebarTrigger({
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
- size="icon"
- className={cn("size-7", className)}
+ size="icon-sm"
+ className={cn(className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
-
+
Toggle Sidebar
)
@@ -291,7 +290,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
- "absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex",
+ "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",
@@ -309,8 +308,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
) {
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
- "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
+ "no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
@@ -395,46 +393,50 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
function SidebarGroupLabel({
className,
- asChild = false,
+ render,
...props
-}: React.ComponentProps<"div"> & { asChild?: boolean }) {
- const Comp = asChild ? Slot.Root : "div"
-
- return (
- svg]:size-4 [&>svg]:shrink-0",
- "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
- className
- )}
- {...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,
- asChild = false,
+ render,
...props
-}: React.ComponentProps<"button"> & { asChild?: boolean }) {
- const Comp = asChild ? Slot.Root : "button"
-
- return (
- svg]:size-4 [&>svg]:shrink-0",
- // Increases the hit area of the button on mobile.
- "after:absolute after:-inset-2 md:after:hidden",
- "group-data-[collapsible=icon]:hidden",
- className
- )}
- {...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({
@@ -456,7 +458,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
)
@@ -474,7 +476,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
}
const sidebarMenuButtonVariants = cva(
- "peer/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-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ "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: {
@@ -496,34 +498,38 @@ const sidebarMenuButtonVariants = cva(
)
function SidebarMenuButton({
- asChild = false,
+ render,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
-}: React.ComponentProps<"button"> & {
- asChild?: boolean
- isActive?: boolean
- tooltip?: string | React.ComponentProps
-} & VariantProps) {
- const Comp = asChild ? Slot.Root : "button"
+}: useRender.ComponentProps<"button"> &
+ React.ComponentProps<"button"> & {
+ isActive?: boolean
+ tooltip?: string | React.ComponentProps
+ } & VariantProps) {
const { isMobile, state } = useSidebar()
-
- const button = (
-
- )
+ const comp = useRender({
+ defaultTagName: "button",
+ props: mergeProps<"button">(
+ {
+ className: cn(sidebarMenuButtonVariants({ variant, size }), className),
+ },
+ props
+ ),
+ render: !tooltip ? render : ,
+ state: {
+ slot: "sidebar-menu-button",
+ sidebar: "menu-button",
+ size,
+ active: isActive,
+ },
+ })
if (!tooltip) {
- return button
+ return comp
}
if (typeof tooltip === "string") {
@@ -534,7 +540,7 @@ function SidebarMenuButton({
return (
- {button}
+ {comp}
& {
- asChild?: boolean
- showOnHover?: boolean
-}) {
- const Comp = asChild ? Slot.Root : "button"
-
- return (
- svg]:size-4 [&>svg]:shrink-0",
- // Increases the hit area of the button on mobile.
- "after:absolute after:-inset-2 md:after:hidden",
- "peer-data-[size=sm]/menu-button:top-1",
- "peer-data-[size=default]/menu-button:top-1.5",
- "peer-data-[size=lg]/menu-button:top-2.5",
- "group-data-[collapsible=icon]:hidden",
- showOnHover &&
- "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0",
- className
- )}
- {...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({
@@ -586,12 +590,7 @@ function SidebarMenuBadge({
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",
- "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
- "peer-data-[size=sm]/menu-button:top-1",
- "peer-data-[size=default]/menu-button:top-1.5",
- "peer-data-[size=lg]/menu-button:top-2.5",
- "group-data-[collapsible=icon]:hidden",
+ "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}
@@ -607,9 +606,9 @@ function SidebarMenuSkeleton({
showIcon?: boolean
}) {
// Random width between 50 to 90%.
- const width = React.useMemo(() => {
+ const [width] = React.useState(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
- }, [])
+ })
return (
) {
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",
+ "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}
@@ -667,35 +665,35 @@ function SidebarMenuSubItem({
}
function SidebarMenuSubButton({
- asChild = false,
+ render,
size = "md",
isActive = false,
className,
...props
-}: React.ComponentProps<"a"> & {
- asChild?: boolean
- size?: "sm" | "md"
- isActive?: boolean
-}) {
- const Comp = asChild ? Slot.Root : "a"
-
- return (
-
span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
- "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
- size === "sm" && "text-xs",
- size === "md" && "text-sm",
- "group-data-[collapsible=icon]:hidden",
- 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 {
diff --git a/apps/admin/src/components/ui/skeleton.tsx b/apps/admin/src/components/ui/skeleton.tsx
index 3ec6be7..0118624 100644
--- a/apps/admin/src/components/ui/skeleton.tsx
+++ b/apps/admin/src/components/ui/skeleton.tsx
@@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
)
diff --git a/apps/admin/src/components/ui/table.tsx b/apps/admin/src/components/ui/table.tsx
new file mode 100644
index 0000000..8dc13ae
--- /dev/null
+++ b/apps/admin/src/components/ui/table.tsx
@@ -0,0 +1,116 @@
+"use client"
+
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+ )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ )
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+ tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/apps/admin/src/components/ui/textarea.tsx b/apps/admin/src/components/ui/textarea.tsx
new file mode 100644
index 0000000..04d27f7
--- /dev/null
+++ b/apps/admin/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/apps/admin/src/components/ui/tooltip.tsx b/apps/admin/src/components/ui/tooltip.tsx
index ec65c1e..b23dcda 100644
--- a/apps/admin/src/components/ui/tooltip.tsx
+++ b/apps/admin/src/components/ui/tooltip.tsx
@@ -1,55 +1,64 @@
"use client"
-import * as React from "react"
-import { Tooltip as TooltipPrimitive } from "radix-ui"
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
- delayDuration = 0,
+ delay = 0,
...props
-}: React.ComponentProps) {
+}: TooltipPrimitive.Provider.Props) {
return (
)
}
-function Tooltip({
- ...props
-}: React.ComponentProps) {
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return
}
-function TooltipTrigger({
- ...props
-}: React.ComponentProps) {
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return
}
function TooltipContent({
className,
- sideOffset = 0,
+ side = "top",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
children,
...props
-}: React.ComponentProps) {
+}: TooltipPrimitive.Popup.Props &
+ Pick<
+ TooltipPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset"
+ >) {
return (
-
- {children}
-
-
+
+ {children}
+
+
+
)
}
diff --git a/convex/products.ts b/convex/products.ts
index e7bbaca..b86793f 100644
--- a/convex/products.ts
+++ b/convex/products.ts
@@ -557,6 +557,14 @@ export const getById = internalQuery({
},
});
+export const getByIdForAdmin = query({
+ args: { id: v.id("products") },
+ handler: async (ctx, { id }) => {
+ await Users.requireAdmin(ctx);
+ return getProductWithRelations(ctx, id);
+ },
+});
+
/**
* One-time migration: backfill parentCategorySlug, childCategorySlug, topCategorySlug
* from categories. Run once after deploying Phase 1 schema.
@@ -704,13 +712,27 @@ export const create = mutation({
name: v.string(),
slug: v.string(),
description: v.optional(v.string()),
+ shortDescription: v.optional(v.string()),
status: v.union(
v.literal("active"),
v.literal("draft"),
v.literal("archived"),
),
categoryId: v.id("categories"),
+ brand: v.optional(v.string()),
tags: v.array(v.string()),
+ attributes: v.optional(
+ v.object({
+ petSize: v.optional(v.array(v.string())),
+ ageRange: v.optional(v.array(v.string())),
+ specialDiet: v.optional(v.array(v.string())),
+ material: v.optional(v.string()),
+ flavor: v.optional(v.string()),
+ }),
+ ),
+ seoTitle: v.optional(v.string()),
+ seoDescription: v.optional(v.string()),
+ canonicalSlug: v.optional(v.string()),
},
handler: async (ctx, args) => {
await Users.requireAdmin(ctx);
@@ -726,6 +748,8 @@ export const create = mutation({
parentCategorySlug,
childCategorySlug,
...(topCategorySlug !== undefined && { topCategorySlug }),
+ createdAt: Date.now(),
+ updatedAt: Date.now(),
});
},
});
@@ -736,6 +760,7 @@ export const update = mutation({
name: v.optional(v.string()),
slug: v.optional(v.string()),
description: v.optional(v.string()),
+ shortDescription: v.optional(v.string()),
status: v.optional(
v.union(
v.literal("active"),
@@ -744,7 +769,20 @@ export const update = mutation({
),
),
categoryId: v.optional(v.id("categories")),
+ brand: v.optional(v.string()),
tags: v.optional(v.array(v.string())),
+ attributes: v.optional(
+ v.object({
+ petSize: v.optional(v.array(v.string())),
+ ageRange: v.optional(v.array(v.string())),
+ specialDiet: v.optional(v.array(v.string())),
+ material: v.optional(v.string()),
+ flavor: v.optional(v.string()),
+ }),
+ ),
+ seoTitle: v.optional(v.string()),
+ seoDescription: v.optional(v.string()),
+ canonicalSlug: v.optional(v.string()),
},
handler: async (ctx, { id, ...updates }) => {
await Users.requireAdmin(ctx);
@@ -769,6 +807,8 @@ export const update = mutation({
}
}
+ fields.updatedAt = Date.now();
+
await ctx.db.patch(id, fields);
return id;
},
diff --git a/package-lock.json b/package-lock.json
index 0f31c04..b074ae5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -47,6 +47,7 @@
"dependencies": {
"@base-ui/react": "^1.2.0",
"@clerk/nextjs": "^6.38.2",
+ "@hookform/resolvers": "^5.2.2",
"@hugeicons/core-free-icons": "^3.3.0",
"@hugeicons/react": "^1.1.5",
"@repo/convex": "*",
@@ -56,7 +57,9 @@
"clsx": "^2.1.1",
"lucide-react": "^0.400.0",
"radix-ui": "^1.4.3",
- "tailwind-merge": "^2.6.1"
+ "react-hook-form": "^7.71.2",
+ "tailwind-merge": "^2.6.1",
+ "zod": "^3.25.76"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.0",
@@ -1769,6 +1772,18 @@
"hono": "^4"
}
},
+ "node_modules/@hookform/resolvers": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
+ "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/utils": "^0.3.0"
+ },
+ "peerDependencies": {
+ "react-hook-form": "^7.55.0"
+ }
+ },
"node_modules/@hugeicons/core-free-icons": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.3.0.tgz",
@@ -8522,6 +8537,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
"node_modules/@stripe/react-stripe-js": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.6.0.tgz",
@@ -15605,6 +15626,23 @@
"react": "^19.2.4"
}
},
+ "node_modules/react-hook-form": {
+ "version": "7.71.2",
+ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
+ "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/react-hook-form"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17 || ^18 || ^19"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -18161,7 +18199,6 @@
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
- "dev": true,
"license": "MIT",
"peer": true,
"funding": {