Compare commits
10 Commits
cc15338ad9
...
e28a6524a3
| Author | SHA1 | Date | |
|---|---|---|---|
| e28a6524a3 | |||
| 5f7c3cece9 | |||
| 0f91d3dc05 | |||
| 83a5172397 | |||
| 3d50cb895c | |||
| 8e4309892c | |||
| 1ea527ca1f | |||
| 5168553bae | |||
| 2dc8878db7 | |||
| a897089fdc |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,3 +31,6 @@ yarn-error.log*
|
||||
|
||||
# Convex
|
||||
convex/_generated
|
||||
apps/admin/.env.staging
|
||||
apps/storefront/.env.staging
|
||||
convex/.env.staging
|
||||
|
||||
18
.mcp.json
Normal file
18
.mcp.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"shadcn": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"shadcn@latest",
|
||||
"mcp"
|
||||
]
|
||||
},
|
||||
"hugeicons": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@hugeicons/mcp-server"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
114
apps/admin/CLAUDE.md
Normal file
114
apps/admin/CLAUDE.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Admin Dashboard UI — Implementation Rules
|
||||
|
||||
Applies to all UI work inside `apps/admin/`. These rules take precedence over
|
||||
general project conventions for anything under this directory.
|
||||
|
||||
---
|
||||
|
||||
## 1. Component Library — ShadCN UI Only
|
||||
|
||||
All UI must be composed strictly from ShadCN UI components. No other component
|
||||
libraries, no raw HTML element styling where a ShadCN component exists.
|
||||
|
||||
**Before building any UI, check if ShadCN has a component for it:**
|
||||
|
||||
| Category | Components |
|
||||
|--------------|-----------|
|
||||
| Layout | `Sheet`, `Separator`, `ScrollArea`, `ResizablePanel` |
|
||||
| Navigation | `NavigationMenu`, `Breadcrumb`, `Tabs` |
|
||||
| Data display | `Table`, `Card`, `Badge`, `Avatar` |
|
||||
| Forms | `Form`, `Input`, `Select`, `Checkbox`, `Switch`, `Textarea`, `RadioGroup`, `DatePicker` |
|
||||
| Feedback | `Toast` (via Sonner), `Alert`, `Dialog`, `AlertDialog` |
|
||||
| Loading | `Skeleton` |
|
||||
| Actions | `Button`, `DropdownMenu`, `ContextMenu`, `Command` |
|
||||
|
||||
**Install components via the ShadCN CLI — never copy-paste or hand-write ShadCN component source:**
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add button
|
||||
npx shadcn@latest add table
|
||||
```
|
||||
|
||||
**ShadCN best practices:**
|
||||
|
||||
- Use `cn()` from `lib/utils.ts` for all conditional className merging — never string concatenation
|
||||
- Extend ShadCN components via `className` props, never modify files in `components/ui/` directly
|
||||
- Use `variant` and `size` props before reaching for custom styles
|
||||
- Compose complex components by combining primitives — a stat card is `Card` + `CardHeader` + `CardContent`, not a custom div
|
||||
- Use `asChild` when you need to change the rendered element (e.g. wrapping a Next.js `Link` in a `Button`)
|
||||
|
||||
---
|
||||
|
||||
## 2. Skills & MCP Usage
|
||||
|
||||
**Always invoke the `shadcn-ui` skill** before starting any new page or significant component:
|
||||
|
||||
```
|
||||
/shadcn-ui
|
||||
```
|
||||
|
||||
The skill guides intentional layout, spacing, and visual hierarchy decisions within the ShadCN constraint.
|
||||
|
||||
**If the ShadCN MCP server is available**, use it to look up component APIs before implementing. Do not guess prop names or variant values from memory.
|
||||
|
||||
**When MCP is unavailable**, refer to https://ui.shadcn.com/docs/components before writing component usage.
|
||||
|
||||
---
|
||||
|
||||
## 3. No SEO
|
||||
|
||||
- No `<Head>` metadata beyond the bare minimum `layout.tsx` title
|
||||
- No `generateMetadata` functions on admin pages
|
||||
- No Open Graph, Twitter card, or structured data tags
|
||||
- No sitemap or robots.txt entries for admin routes
|
||||
|
||||
---
|
||||
|
||||
## 4. Accessibility — Required Minimums
|
||||
|
||||
ShadCN handles most accessibility via Radix UI primitives. Additionally ensure:
|
||||
|
||||
- All interactive elements are keyboard navigable (use ShadCN correctly and this is automatic)
|
||||
- Form fields always have an associated `<label>` — use ShadCN `Label`; use `sr-only` if the design hides it visually
|
||||
- Data tables include `scope` on `<th>` elements
|
||||
- Icon-only buttons always have `aria-label` — e.g. `<Button aria-label="Delete product" size="icon"><Trash2 /></Button>`
|
||||
- Modal dialogs use ShadCN `Dialog` or `AlertDialog` — never custom divs with `display:none` toggling
|
||||
- Color is never the only indicator of state — always pair color with text or icon
|
||||
- Focus rings must remain visible — never add `outline-none` without a replacement focus style
|
||||
|
||||
---
|
||||
|
||||
## 5. Code Quality
|
||||
|
||||
- Admin page components are **Server Components by default**. Add `"use client"` only when the component uses hooks, event handlers, or browser APIs
|
||||
- Data fetching happens in Server Components via Convex server-side queries — not in `useEffect`
|
||||
- Forms use `react-hook-form` + `zod` validation wired through ShadCN `Form` components
|
||||
- Loading states use ShadCN `Skeleton` — never spinners on full page loads
|
||||
- Form submit buttons **must** show a spinner while submitting. Use an inline SVG with `data-icon="inline-start"` and `animate-spin` placed before the label text, and set `disabled={isSubmitting}`. The button label should also change to reflect the in-progress state (e.g. "Creating…", "Saving…"):
|
||||
```tsx
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
<svg data-icon="inline-start" className="animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
{isSubmitting ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
```
|
||||
- Destructive actions (delete, archive) always use `AlertDialog` for confirmation — never `window.confirm`
|
||||
- Empty states are always handled explicitly — never render an empty table or blank page silently
|
||||
|
||||
---
|
||||
|
||||
## 6. Imports
|
||||
|
||||
The admin app tsconfig has **no path alias**. Use relative imports only:
|
||||
|
||||
```typescript
|
||||
// Correct
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
// Wrong — no @/ alias in this app
|
||||
import { cn } from "@/lib/utils";
|
||||
```
|
||||
25
apps/admin/components.json
Normal file
25
apps/admin/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "hugeicons",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
273
apps/admin/docs/00-admin-dashboard-feature-checklist.md
Normal file
273
apps/admin/docs/00-admin-dashboard-feature-checklist.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# Admin Dashboard — Feature Checklist
|
||||
|
||||
**Date:** 2026-03-04
|
||||
**Audience:** Senior software engineers, project stakeholders
|
||||
|
||||
---
|
||||
|
||||
## How to Read This Document
|
||||
|
||||
Features are grouped into **MVP** (required for launch) and **Post-MVP** (phased rollout). Within each group, features are ordered by implementation priority.
|
||||
|
||||
**Legend:**
|
||||
|
||||
| Symbol | Meaning |
|
||||
|--------|---------|
|
||||
| `[ ]` | Not started |
|
||||
| `[~]` | Backend exists, admin UI needed |
|
||||
| `[x]` | Complete |
|
||||
| **BE** | Backend work required (new Convex functions) |
|
||||
| **UI** | Admin frontend work only |
|
||||
| **3P** | Third-party integration required |
|
||||
|
||||
---
|
||||
|
||||
## MVP
|
||||
|
||||
### 1. Authentication & Authorization
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 1.1 | Clerk sign-in page (branded, in-app) | `[x]` UI | Replace hosted sign-in redirect |
|
||||
| 1.2 | Admin user sync (Convex record on sign-in) | `[x]` UI | Wire existing `useStoreUserEffect` hook |
|
||||
| 1.3 | Role-based auth gate (block non-admin users) | `[x]` UI | `AdminAuthGate` component, query `users.current` |
|
||||
| 1.4 | Access denied page for customers | `[x]` UI | Sign-out button + storefront link |
|
||||
| 1.5 | Admin layout shell (header with `UserButton`) | `[x]` UI | Persistent header with session management |
|
||||
| 1.6 | Route group structure (`(auth)` vs `(dashboard)`) | `[x]` UI | Separate sign-in from protected routes |
|
||||
|
||||
> Full implementation plan: [05-admin-auth-implementation-plan.md](./05-admin-auth-implementation-plan.md)
|
||||
|
||||
---
|
||||
|
||||
### 2. Navigation & Layout
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 2.1 | Sidebar navigation | `[x]` UI | Collapsible; links to all admin sections |
|
||||
| 2.2 | Breadcrumbs | `[x]` UI | Context-aware breadcrumb trail |
|
||||
| 2.3 | Mobile-responsive admin shell | `[x]` UI | Hamburger menu on mobile, full sidebar on `lg:` |
|
||||
| 2.4 | Active route highlighting | `[x]` UI | Visual indicator for current section |
|
||||
|
||||
---
|
||||
|
||||
### 3. Product Management (Inventory)
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 3.1 | Product list page | `[x]` UI | Backend: `products.list` (paginated, filterable by status/category). Build table with search, filters, pagination. |
|
||||
| 3.2 | Create product form | `[x]` UI | Backend: `products.create`. Form: name, slug, description, status, category, tags. |
|
||||
| 3.3 | Edit product form | `[x]` UI | Backend: `products.update`. Pre-populated form with all fields. |
|
||||
| 3.4 | Archive/restore product | `[x]` UI | Backend: `products.archive`. Confirmation dialog. Restore via edit status field. |
|
||||
| 3.5 | Product image upload | `[x]` BE+UI | Cloudinary server-side upload via Next.js API route (`/api/upload-image`). Background removal (Image Processing API) with dual upload: processed or original. Structured `public_id` + `asset_folder` per product. |
|
||||
| 3.6 | Image gallery management | `[x]` UI | Drag-and-drop reorder (`@dnd-kit`) with `reorderImages` mutation. Per-image delete with AlertDialog. "Add more" tile. Search-driven product selection with auto-clear. |
|
||||
| 3.7 | Variant management | `[x]` UI | Variants page with table, create/edit/preview dialogs, activate/deactivate, delete with AlertDialog. |
|
||||
| 3.8 | Stock quantity editing | `[x]` UI | `stockQuantity` editable in full variant edit dialog. |
|
||||
| 3.9 | Price and compare-at-price editing | `[x]` UI | `price` and `compareAtPrice` (behind On Sale toggle) editable in variant form. |
|
||||
| 3.10 | Product SEO fields (title, description) | `[x]` UI | `seoTitle`, `seoDescription`, `canonicalSlug` in collapsible Advanced/SEO section. |
|
||||
| 3.11 | Product search within admin | `[x]` UI | Debounced search bar on list page; switches between `products.list` and `products.search`. |
|
||||
| 3.12 | Bulk status change (draft → active, etc.) | `[ ]` BE+UI | New mutation: `products.bulkUpdateStatus`. Multi-select in table. |
|
||||
|
||||
---
|
||||
|
||||
### 4. Category Management
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 4.1 | Category list / tree view | `[~]` UI | Backend: `categories.list` (supports `parentId`). Show hierarchical tree. |
|
||||
| 4.2 | Create category | `[~]` UI | Backend: `categories.create`. Form: name, slug, parent, top-category slug, SEO. |
|
||||
| 4.3 | Edit category | `[~]` UI | Backend: `categories.update`. |
|
||||
| 4.4 | Category image upload | `[ ]` BE+UI | Schema has `imageUrl`. Need file upload flow. |
|
||||
|
||||
---
|
||||
|
||||
### 5. Order Processing
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 5.1 | Order list page | `[~]` UI | Backend: `orders.listAll` (paginated, filterable by status/paymentStatus). |
|
||||
| 5.2 | Order detail page | `[~]` UI | Backend: `orders.getById` (returns items, addresses, payment info). |
|
||||
| 5.3 | Update order status | `[~]` UI | Backend: `orders.updateStatus`. Dropdown or status stepper. |
|
||||
| 5.4 | Cancel order (admin-initiated) | `[ ]` BE+UI | New mutation: `orders.adminCancel` — cancel regardless of customer rules, update Stripe if paid, restore stock. |
|
||||
| 5.5 | Create shipping label (Shippo) | `[ ]` BE+UI+3P | New action: `shippo.createLabel` — calls Shippo Transactions API. Store `trackingNumber`, `trackingUrl`, `labelUrl` on order. |
|
||||
| 5.6 | Print shipping label | `[ ]` UI+3P | Fetch label PDF URL from Shippo, open in new tab / trigger print dialog. |
|
||||
| 5.7 | Track shipment status | `[ ]` BE+3P | New: Shippo tracking webhook → update order `status` and `trackingUrl`. Or poll Shippo Tracking Status API. |
|
||||
| 5.8 | Refund order (full) | `[ ]` BE+UI+3P | New action: `stripe.refundPayment` — calls Stripe Refunds API. Update `paymentStatus` to `"refunded"`, `status` to `"refunded"`. |
|
||||
| 5.9 | Partial refund | `[ ]` BE+UI+3P | Same Stripe Refunds API with `amount` parameter. |
|
||||
| 5.10 | Return processing | `[ ]` BE+UI | New: `returns` table or status sub-flow. Accept return request → inspect → refund or reject. |
|
||||
| 5.11 | Send order update email | `[ ]` BE+3P | New: email service integration (Resend or SendGrid). Triggered on status changes: confirmed, shipped (with tracking), delivered, cancelled, refunded. |
|
||||
| 5.12 | Send order update SMS | `[ ]` BE+3P | New: SMS integration (Twilio or similar). Triggered on key status changes: shipped, delivered. |
|
||||
| 5.13 | Order notes (internal) | `[~]` UI | Schema has `notes` field. Admin can add/edit internal notes. |
|
||||
| 5.14 | Order search / filters | `[ ]` BE+UI | Search by order number, customer email, date range. May need new indexes. |
|
||||
| 5.15 | Batch label creation | `[ ]` BE+UI+3P | Select multiple orders → create labels via Shippo Batches API. |
|
||||
|
||||
---
|
||||
|
||||
### 6. Customer Management (MVP-lite)
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 6.1 | Customer list page | `[~]` UI | Backend: `users.listCustomers` (paginated). |
|
||||
| 6.2 | Customer detail page | `[ ]` BE+UI | New query: `users.getCustomerDetail` — user + orders + addresses. |
|
||||
| 6.3 | View customer orders | `[ ]` UI | Link from customer detail to filtered order list. |
|
||||
|
||||
---
|
||||
|
||||
## Post-MVP
|
||||
|
||||
### 7. Dashboard & Analytics
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 7.1 | Dashboard home — key metrics | `[ ]` BE+UI | New queries: total revenue, order count, new customers (time-windowed). `DashboardStats` type already exists in `@repo/types`. |
|
||||
| 7.2 | Revenue chart (daily/weekly/monthly) | `[ ]` BE+UI | New query: aggregated revenue by period. Chart library (Recharts or similar). |
|
||||
| 7.3 | Orders chart | `[ ]` BE+UI | Order volume over time. |
|
||||
| 7.4 | Top-selling products | `[ ]` BE+UI | New query: aggregate `orderItems` by product, sort by quantity. |
|
||||
| 7.5 | Low stock alerts | `[ ]` BE+UI | New query: variants where `stockQuantity` < threshold. Dashboard widget + notification badge. |
|
||||
| 7.6 | Recent orders feed | `[~]` UI | Backend: `orders.listAll` with `limit`. Real-time feed on dashboard. |
|
||||
| 7.7 | Conversion funnel | `[ ]` BE+UI | Track: visits → cart adds → checkouts → completed orders. Requires analytics events. |
|
||||
|
||||
---
|
||||
|
||||
### 8. Review Management
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 8.1 | Review list page (all reviews) | `[~]` UI | Backend: `reviews.listForAdmin` (filterable by approval status, product). |
|
||||
| 8.2 | Approve review | `[~]` UI | Backend: `reviews.approve`. |
|
||||
| 8.3 | Delete review | `[~]` UI | Backend: `reviews.deleteReview`. |
|
||||
| 8.4 | Review detail / preview | `[ ]` UI | Show full review content, images, linked product. |
|
||||
| 8.5 | Bulk approve/delete | `[ ]` BE+UI | New mutations for batch operations. |
|
||||
|
||||
---
|
||||
|
||||
### 9. Customer Communication
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 9.1 | Contact form messages inbox | `[ ]` BE+UI | New `messages` table. Storefront contact form → Convex. Admin reads/replies. |
|
||||
| 9.2 | Reply to customer message | `[ ]` BE+UI+3P | Send reply via email (Resend/SendGrid). Store thread in Convex. |
|
||||
| 9.3 | Message status (unread/read/resolved) | `[ ]` BE+UI | Status field on messages table. |
|
||||
| 9.4 | Email templates | `[ ]` BE+3P | Transactional email templates for order updates, review responses, etc. |
|
||||
|
||||
---
|
||||
|
||||
### 10. Newsletter & Marketing
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 10.1 | Newsletter subscriber list | `[ ]` BE+UI | New `subscribers` table. Storefront signup → Convex. Admin views list. |
|
||||
| 10.2 | Export subscribers (CSV) | `[ ]` UI | Client-side CSV generation from subscriber list. |
|
||||
| 10.3 | Compose & send newsletter | `[ ]` BE+3P | Integration with email provider (Resend/Mailchimp). Template editor. |
|
||||
| 10.4 | Unsubscribe handling | `[ ]` BE | Unsubscribe link in emails → Convex mutation. |
|
||||
|
||||
---
|
||||
|
||||
### 11. Promotions & Discounts
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 11.1 | Coupon/discount code management | `[ ]` BE+UI+3P | New `coupons` table or Stripe Coupons API (already available via MCP). CRUD UI for codes, percentage/fixed amount, expiry, usage limits. |
|
||||
| 11.2 | Sale tag management | `[~]` UI | Products already have `tags[]`. Admin can add/remove "sale" tag. Backend: `products.update`. |
|
||||
| 11.3 | Compare-at-price (was/now pricing) | `[~]` UI | Schema has `compareAtPrice` on variants. Editable in variant management. |
|
||||
|
||||
---
|
||||
|
||||
### 12. Admin User Management
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 12.1 | Admin/staff list | `[ ]` BE+UI | New query: `users.listAdmins`. |
|
||||
| 12.2 | Promote user to admin | `[ ]` BE+UI | New mutation: `users.setRole` (super_admin only). |
|
||||
| 12.3 | Demote admin to customer | `[ ]` BE+UI | Same `users.setRole` mutation. |
|
||||
| 12.4 | Activity / audit log | `[ ]` BE+UI | New `auditLogs` table. Log admin actions with userId, action, target, timestamp. |
|
||||
|
||||
---
|
||||
|
||||
### 13. Settings & Configuration
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 13.1 | Store settings (name, logo, contact info) | `[ ]` BE+UI | New `storeSettings` table (singleton). |
|
||||
| 13.2 | Shipping configuration | `[ ]` BE+UI | Default parcel dimensions, weight limits, carrier preferences. Currently hardcoded in `model/shippo.ts`. |
|
||||
| 13.3 | Tax configuration | `[ ]` BE+UI | Tax rates by region. Currently `tax` is passed manually on order creation. |
|
||||
| 13.4 | Email notification preferences | `[ ]` BE+UI | Which status changes trigger emails/SMS. |
|
||||
| 13.5 | Payment settings (Stripe config) | `[ ]` UI | Display Stripe connection status, webhook health. |
|
||||
|
||||
---
|
||||
|
||||
### 14. Data & Export
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 14.1 | Export orders (CSV) | `[ ]` UI | Client-side CSV from `orders.listAll`. |
|
||||
| 14.2 | Export products (CSV) | `[ ]` UI | Client-side CSV from `products.listAll`. |
|
||||
| 14.3 | Export customers (CSV) | `[ ]` UI | Client-side CSV from `users.listCustomers`. |
|
||||
| 14.4 | Import products (CSV) | `[ ]` BE+UI | Parse CSV → batch `products.create` calls. |
|
||||
|
||||
---
|
||||
|
||||
## MVP Scope Summary
|
||||
|
||||
| Section | Features | New Backend Work | Third-Party |
|
||||
|---------|----------|------------------|-------------|
|
||||
| 1. Auth & Authorization | 6 | None | — |
|
||||
| 2. Navigation & Layout | 4 | None | — |
|
||||
| 3. Product Management | 12 | File upload, bulk status | — |
|
||||
| 4. Category Management | 4 | File upload | — |
|
||||
| 5. Order Processing | 15 | Cancel, refund, label, tracking, return, email, SMS, search, batch | Shippo, Stripe, Resend/SendGrid, Twilio |
|
||||
| 6. Customer Management | 3 | Customer detail query | — |
|
||||
| **Total MVP** | **44** | | |
|
||||
|
||||
---
|
||||
|
||||
## Third-Party Integration Summary
|
||||
|
||||
| Service | Purpose | MVP? | Existing? |
|
||||
|---------|---------|------|-----------|
|
||||
| **Clerk** | Authentication | Yes | Yes — sign-in, JWT, webhooks |
|
||||
| **Convex** | Backend, real-time DB | Yes | Yes — full schema + functions |
|
||||
| **Stripe** | Payments, refunds | Yes | Partial — checkout exists, refunds needed |
|
||||
| **Shippo** | Shipping labels, tracking | Yes | Partial — rates/validation exist, labels/tracking needed |
|
||||
| **Resend** or **SendGrid** | Transactional email | Yes | No — not integrated |
|
||||
| **Twilio** or **SNS** | SMS notifications | Yes | No — not integrated |
|
||||
| **Recharts** or **Chart.js** | Dashboard charts | Post-MVP | No |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Implementation Order (MVP)
|
||||
|
||||
```
|
||||
Phase 1 ─ Auth & Layout (1-2 days)
|
||||
├─ 1.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.
|
||||
1049
apps/admin/docs/01-admin-auth-implementation-plan.md
Normal file
1049
apps/admin/docs/01-admin-auth-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
304
apps/admin/docs/02-navigation-layout-implementation-plan.md
Normal file
304
apps/admin/docs/02-navigation-layout-implementation-plan.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# Navigation & Layout — Implementation Plan
|
||||
|
||||
**Date:** 2026-03-04
|
||||
**Audience:** Senior software engineers
|
||||
**Scope:** `apps/admin` — checklist section 2 (Navigation & Layout)
|
||||
|
||||
---
|
||||
|
||||
## 1. Current State
|
||||
|
||||
### What exists
|
||||
|
||||
| Component | File | Status |
|
||||
|-----------|------|--------|
|
||||
| `AppSidebar` | `components/layout/sidebar/app-sidebar.tsx` | Done — renders header, nav groups, footer |
|
||||
| `NavMain` | `components/layout/sidebar/nav-main.tsx` | Done — collapsible nav + flat nav via `isOverview` flag |
|
||||
| `NavUser` | `components/layout/sidebar/nav-user.tsx` | Done — `UserButton` + name/email, collapses with sidebar |
|
||||
| `NAV_LINKS` | `lib/constants/app.constants.ts` | Done — complete route tree (overview, navMain, users) |
|
||||
| Dashboard layout | `app/(dashboard)/layout.tsx` | Partial — wires `SidebarProvider` + `SidebarInset`; header slot has only `SidebarTrigger` + `Separator` |
|
||||
| ShadCN UI | `components/ui/sidebar`, `collapsible`, `breadcrumb`, `separator`, `tooltip`, `skeleton` | Installed |
|
||||
|
||||
### What is missing
|
||||
|
||||
| # | Checklist item | Gap |
|
||||
|---|----------------|-----|
|
||||
| 2.1 | Sidebar navigation | Functional but `isActive` is hardcoded in `NAV_LINKS`; active state is not driven by current URL |
|
||||
| 2.2 | Breadcrumbs | Not implemented; header slot in `(dashboard)/layout.tsx` is empty after `SidebarTrigger` |
|
||||
| 2.3 | Mobile-responsive shell | ShadCN `Sidebar` handles mobile via `Sheet` — the `SidebarTrigger` in the header is the hamburger. Behaviour needs verification and a polish pass |
|
||||
| 2.4 | Active route highlighting | Blocked by 2.1 — `isActive` must come from `usePathname()`, not static data |
|
||||
|
||||
### Architecture overview (current)
|
||||
|
||||
```
|
||||
(dashboard)/layout.tsx
|
||||
└── SidebarProvider
|
||||
├── AppSidebar ← collapsible="icon"
|
||||
│ ├── SidebarHeader (logo)
|
||||
│ ├── SidebarContent
|
||||
│ │ ├── NavMain (Platform) ← overview flat links
|
||||
│ │ ├── NavMain (Application) ← collapsible groups
|
||||
│ │ └── NavMain (Users) ← flat link
|
||||
│ ├── SidebarFooter
|
||||
│ │ └── NavUser (UserButton)
|
||||
│ └── SidebarRail
|
||||
└── SidebarInset
|
||||
├── <header>
|
||||
│ ├── SidebarTrigger ← hamburger on mobile, collapse toggle on desktop
|
||||
│ └── Separator
|
||||
│ └── [BREADCRUMB SLOT — empty]
|
||||
└── <main>{children}</main>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Target Architecture
|
||||
|
||||
```
|
||||
(dashboard)/layout.tsx
|
||||
└── SidebarProvider
|
||||
├── AppSidebar ← unchanged structure
|
||||
│ ├── SidebarHeader (logo)
|
||||
│ ├── SidebarContent
|
||||
│ │ ├── NavMain (Platform) ← active state from usePathname()
|
||||
│ │ ├── NavMain (Application) ← active state + auto-opens active group
|
||||
│ │ └── NavMain (Users) ← active state from usePathname()
|
||||
│ ├── SidebarFooter
|
||||
│ │ └── NavUser
|
||||
│ └── SidebarRail
|
||||
└── SidebarInset
|
||||
├── <header>
|
||||
│ ├── SidebarTrigger
|
||||
│ ├── Separator
|
||||
│ └── DynamicBreadcrumb ← NEW: reads pathname → renders ShadCN Breadcrumb
|
||||
└── <main>{children}</main>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Design Decisions
|
||||
|
||||
### D1: Active state via `usePathname()` — not static data
|
||||
|
||||
`isActive` booleans in `NAV_LINKS` are unreliable (they are snapshots, not reactive). `NavMain` should call `usePathname()` internally and derive active state at render time.
|
||||
|
||||
- Flat links: `isActive = pathname === item.url`
|
||||
- Collapsible groups: `isActive = pathname.startsWith(item.url)` — also controls `defaultOpen`
|
||||
- Sub-items: `isActive = pathname === subItem.url`
|
||||
|
||||
`isActive` fields in `NAV_LINKS` should be removed to avoid confusion.
|
||||
|
||||
### D2: Breadcrumb is a single `DynamicBreadcrumb` component
|
||||
|
||||
The breadcrumb reads `usePathname()`, splits into segments, maps each to a human-readable label via a static `ROUTE_LABELS` map, and renders ShadCN's `Breadcrumb` primitive.
|
||||
|
||||
**Segment matching rules:**
|
||||
- Known static segments → label from map (e.g. `"products"` → `"Products"`)
|
||||
- Dynamic segments (Convex IDs, slugs) → displayed as-is for now; individual detail pages can override with a `<PageBreadcrumbItem>` context pattern in a future iteration
|
||||
|
||||
**Home segment:** A `<House />` icon link to `/` instead of the word "Dashboard".
|
||||
|
||||
### D3: Mobile shell — no new code needed
|
||||
|
||||
ShadCN's `Sidebar` primitive (`collapsible="icon"`) renders as an `offcanvas` sheet on mobile automatically (breakpoint `md`). The `SidebarTrigger` in the layout header is already the hamburger. No structural changes required — the plan documents this explicitly so it is not re-investigated.
|
||||
|
||||
### D4: `NAV_LINKS` remains the single source of truth for route structure
|
||||
|
||||
Active state logic is moved into the component, but route definitions (titles, URLs, icons, children) stay in `NAV_LINKS`. This keeps route changes in one place.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Phases
|
||||
|
||||
### Phase 1 — Active route highlighting (2.1 / 2.4)
|
||||
|
||||
**Goal:** Drive `isActive` from `usePathname()` in `NavMain`. Remove hardcoded `isActive` from `NAV_LINKS`.
|
||||
|
||||
#### 1.1 Update `NavMain` to read `usePathname()`
|
||||
|
||||
`NavMain` is already a client component (`"use client"`). Add `usePathname()` from `next/navigation`.
|
||||
|
||||
**Flat link items (overview / users groups):**
|
||||
```tsx
|
||||
const pathname = usePathname();
|
||||
// ...
|
||||
<SidebarMenuButton isActive={pathname === item.url}>
|
||||
```
|
||||
|
||||
**Collapsible group items:**
|
||||
```tsx
|
||||
const isGroupActive = pathname.startsWith(item.url);
|
||||
// defaultOpen driven by isGroupActive
|
||||
<Collapsible defaultOpen={isGroupActive}>
|
||||
<SidebarMenuButton isActive={isGroupActive}>
|
||||
// ...
|
||||
{item.items?.map(subItem => (
|
||||
<SidebarMenuSubButton isActive={pathname === subItem.url}>
|
||||
))}
|
||||
```
|
||||
|
||||
**Edge case — Dashboard `/` link:** Use `pathname === "/"` (exact match only) so the Dashboard link is not active on every page.
|
||||
|
||||
#### 1.2 Remove `isActive` from `NAV_LINKS`
|
||||
|
||||
Remove the `isActive: true` fields from `navMain` items in `app.constants.ts`. The type definition in `NavMain` props should also drop `isActive` from the item shape (or keep it as optional and ignore it — removing is cleaner).
|
||||
|
||||
#### Files changed
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `src/components/layout/sidebar/nav-main.tsx` | Edit — add `usePathname()`, derive `isActive` for flat and collapsible items |
|
||||
| `src/lib/constants/app.constants.ts` | Edit — remove `isActive: true` from `navMain` entries |
|
||||
|
||||
---
|
||||
|
||||
### Phase 2 — Dynamic breadcrumbs (2.2)
|
||||
|
||||
**Goal:** Render a context-aware breadcrumb trail in the dashboard header.
|
||||
|
||||
#### 2.1 Create route label map
|
||||
|
||||
Add to `lib/constants/app.constants.ts`:
|
||||
|
||||
```typescript
|
||||
export const ROUTE_LABELS: Record<string, string> = {
|
||||
orders: "Orders",
|
||||
products: "Products",
|
||||
categories: "Categories",
|
||||
images: "Images",
|
||||
variants: "Variants",
|
||||
customers: "Customers",
|
||||
reviews: "Reviews",
|
||||
messages: "Messages",
|
||||
newsletter: "Newsletter",
|
||||
users: "Users",
|
||||
settings: "Settings",
|
||||
returns: "Returns",
|
||||
};
|
||||
```
|
||||
|
||||
Dynamic segments (Convex IDs) that don't match a key will be displayed as-is (e.g. `"jx7abc123"` → shown literally). This is acceptable for MVP.
|
||||
|
||||
#### 2.2 Create `DynamicBreadcrumb` component
|
||||
|
||||
Create `src/components/layout/DynamicBreadcrumb.tsx`:
|
||||
|
||||
```
|
||||
"use client"
|
||||
|
||||
reads usePathname()
|
||||
splits into segments: "/products/categories" → ["products", "categories"]
|
||||
maps each segment to ROUTE_LABELS[segment] ?? segment
|
||||
renders:
|
||||
- first segment: House icon link to "/"
|
||||
- intermediate segments: BreadcrumbLink
|
||||
- last segment: BreadcrumbPage (not a link)
|
||||
- BreadcrumbSeparator between each item
|
||||
```
|
||||
|
||||
Uses ShadCN components: `Breadcrumb`, `BreadcrumbList`, `BreadcrumbItem`, `BreadcrumbLink`, `BreadcrumbPage`, `BreadcrumbSeparator` from `@/components/ui/breadcrumb`.
|
||||
|
||||
**Special case — root `/`:** No segments → render only "Dashboard" as `BreadcrumbPage` (no links needed).
|
||||
|
||||
#### 2.3 Wire `DynamicBreadcrumb` into dashboard layout header
|
||||
|
||||
Edit `app/(dashboard)/layout.tsx` — the header already has `SidebarTrigger` + `Separator`. Add `<DynamicBreadcrumb />` immediately after:
|
||||
|
||||
```tsx
|
||||
<header className="flex h-12 ...">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<DynamicBreadcrumb />
|
||||
</div>
|
||||
</header>
|
||||
```
|
||||
|
||||
#### Files changed
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `src/lib/constants/app.constants.ts` | Edit — add `ROUTE_LABELS` export |
|
||||
| `src/components/layout/DynamicBreadcrumb.tsx` | Create |
|
||||
| `src/app/(dashboard)/layout.tsx` | Edit — add `<DynamicBreadcrumb />` in header |
|
||||
|
||||
---
|
||||
|
||||
### Phase 3 — Mobile shell verification (2.3)
|
||||
|
||||
**Goal:** Confirm mobile sidebar behaviour works and document it. No new code expected.
|
||||
|
||||
#### 3.1 Verify mobile behavior
|
||||
|
||||
ShadCN's `Sidebar` primitive uses CSS variables and a `data-mobile` attribute (set by a `useIsMobile()` hook inside the primitive) to switch between:
|
||||
- **Desktop (`lg:` and above):** Collapsible to icon rail (`collapsible="icon"`)
|
||||
- **Mobile (below `lg:`):** Renders as a `Sheet` overlay; `SidebarTrigger` toggles it
|
||||
|
||||
The existing `(dashboard)/layout.tsx` already has `SidebarTrigger` in the header — this functions as the hamburger on mobile. No structural changes are required.
|
||||
|
||||
#### 3.2 What to check during implementation
|
||||
|
||||
- `SidebarTrigger` is visible and tappable on mobile viewports
|
||||
- Sidebar opens as sheet overlay (not push) on mobile
|
||||
- Nav links close the sheet on tap (ShadCN handles this via `useSidebar().setOpenMobile(false)` called inside `SidebarMenuButton` when `isMobile` is true)
|
||||
- `NavUser` footer is visible and scrollable on small screens
|
||||
|
||||
#### Files changed
|
||||
|
||||
None — observation only. Document findings in `MEMORY.md` if a fix is needed.
|
||||
|
||||
---
|
||||
|
||||
## 5. File Inventory
|
||||
|
||||
### New files
|
||||
|
||||
| File | Phase | Purpose |
|
||||
|------|-------|---------|
|
||||
| `src/components/layout/DynamicBreadcrumb.tsx` | 2 | Context-aware breadcrumb trail |
|
||||
|
||||
### Modified files
|
||||
|
||||
| File | Phase | Change |
|
||||
|------|-------|--------|
|
||||
| `src/components/layout/sidebar/nav-main.tsx` | 1 | Add `usePathname()` — derive `isActive` for flat + collapsible items |
|
||||
| `src/lib/constants/app.constants.ts` | 1 + 2 | Remove `isActive` from `navMain` entries; add `ROUTE_LABELS` map |
|
||||
| `src/app/(dashboard)/layout.tsx` | 2 | Add `<DynamicBreadcrumb />` in header |
|
||||
|
||||
---
|
||||
|
||||
## 6. Breadcrumb Route Reference
|
||||
|
||||
| Path | Breadcrumb rendered |
|
||||
|------|---------------------|
|
||||
| `/` | Dashboard |
|
||||
| `/orders` | 🏠 / Orders |
|
||||
| `/orders/jx7abc` | 🏠 / Orders / jx7abc |
|
||||
| `/products` | 🏠 / Products |
|
||||
| `/products/categories` | 🏠 / Products / Categories |
|
||||
| `/products/images` | 🏠 / Products / Images |
|
||||
| `/products/variants` | 🏠 / Products / Variants |
|
||||
| `/products/jx7abc` | 🏠 / Products / jx7abc |
|
||||
| `/customers` | 🏠 / Customers |
|
||||
| `/customers/reviews` | 🏠 / Customers / Reviews |
|
||||
| `/customers/messages` | 🏠 / Customers / Messages |
|
||||
| `/customers/newsletter` | 🏠 / Customers / Newsletter |
|
||||
| `/users` | 🏠 / Users |
|
||||
| `/settings` | 🏠 / Settings |
|
||||
| `/returns` | 🏠 / Returns |
|
||||
|
||||
Dynamic segments (IDs/slugs not in `ROUTE_LABELS`) render their raw value. Future detail pages can introduce a breadcrumb context or slot pattern to override with a fetched entity name.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation Order & Effort
|
||||
|
||||
| Phase | Description | Effort |
|
||||
|-------|-------------|--------|
|
||||
| 1 | Active route highlighting | ~30 min |
|
||||
| 2 | Dynamic breadcrumbs | ~45 min |
|
||||
| 3 | Mobile shell verification | ~15 min |
|
||||
|
||||
Total estimated effort: **~1.5 hours**.
|
||||
|
||||
Phases 1 and 2 are independent and can be worked in parallel.
|
||||
240
apps/admin/docs/03-products-feature-implementation-plan.md
Normal file
240
apps/admin/docs/03-products-feature-implementation-plan.md
Normal file
@@ -0,0 +1,240 @@
|
||||
# Products Feature — Implementation Plan (Admin Dashboard)
|
||||
|
||||
**Audience:** Senior software engineers
|
||||
**Scope:** Products route only — list page, create page, edit page. No images or variants.
|
||||
**References:** Convex MCP, ShadCN UI MCP, `.agent/skills/shadcn-ui`, `convex/schema.ts`, `apps/admin` CLAUDE.md and admin-dashboard-ui rule.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Implement the Product Management feature for the admin dashboard as defined in the checklist (items 3.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 `<Link className={buttonVariants({...})}>` — the `Button` component's TypeScript type does not expose the `render` / `nativeButton` props from `@base-ui/react`
|
||||
200
apps/admin/docs/04-images-feature-implementation-plan.md
Normal file
200
apps/admin/docs/04-images-feature-implementation-plan.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# Images Feature — Implementation Plan (Admin Dashboard)
|
||||
|
||||
**Audience:** Senior software engineers
|
||||
**Scope:** Images route only — product search, image gallery per product, upload flow (local select → process → Cloudinary → Convex). No variants.
|
||||
**References:** Convex MCP, ShadCN UI MCP, Cloudinary docs, `.agent/skills/shadcn-ui`, `apps/admin/docs/other/Image_Processing_API.md`, admin CLAUDE.md and admin-dashboard-ui rule.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Implement the Product Images feature for the admin dashboard (checklist items 3.5, 3.6 — product image upload and gallery management), limited to:
|
||||
|
||||
- **Images page** (`/images`) — product search (max 3 results, inline); on product select, show that product’s images in a carousel with delete and “add more”; upload section with local + processed preview and submit to Cloudinary then Convex.
|
||||
- **Flow:** Select product → view/edit gallery (carousel, delete, add) → add: choose file → local preview → call Image Processing API → processed preview (skeleton while loading) → submit: upload to Cloudinary → get URL → `products.addImage` with position.
|
||||
|
||||
**Storage:** Cloudinary (env already: `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, `NEXT_PUBLIC_CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET`).
|
||||
**Processing:** External Image Processing API (background removal) — see `apps/admin/docs/other/Image_Processing_API.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Backend (Convex) — No Schema Change
|
||||
|
||||
Existing API is sufficient:
|
||||
|
||||
| API | Purpose |
|
||||
|-----|---------|
|
||||
| `products.search` | Product search for the images page (query, optional limit). Use limit 3 for inline results. |
|
||||
| `products.getByIdForAdmin` | Load one product with `images` (and variants/category). Images are sorted by `position`. |
|
||||
| `products.addImage` | `{ productId, url, alt?, position }` — insert after upload to Cloudinary. |
|
||||
| `products.deleteImage` | `{ id }` — delete by `productImages._id`. |
|
||||
| `products.reorderImages` | `{ updates: [{ id, position }] }` — set new positions for reorder/drag. |
|
||||
|
||||
**Position when adding:** New image gets `position = max(existing positions) + 1`, or `0` if no images. Compute on the client from current `product.images` before calling `addImage`. After a new add, no need to call `reorderImages` unless the UI allows reordering (e.g. drag in carousel); then send the full new order as `updates`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Image Processing API Integration
|
||||
|
||||
**Spec:** `apps/admin/docs/other/Image_Processing_API.md`.
|
||||
|
||||
- **Endpoint:** `POST /api/remove-background` (multipart/form-data: `file`, optional `format`, `quality`).
|
||||
- **Response:** 200 returns binary image (e.g. WebP); errors return JSON `{ detail: string }`.
|
||||
- **Env:** Add a configurable base URL for the processing API (e.g. `NEXT_PUBLIC_IMAGE_PROCESSING_API_URL` or `IMAGE_PROCESSING_API_URL`). Local: `http://localhost:8000`; production: your deployed URL.
|
||||
|
||||
**Client flow:**
|
||||
|
||||
1. User selects a file (click or drag-and-drop).
|
||||
2. Show local preview (object URL or FileReader).
|
||||
3. Send `POST {baseUrl}/api/remove-background` with `FormData` (`file`, optionally `format: "webp"`, `quality: 95`).
|
||||
4. While waiting, show skeleton in the “processed image” preview area.
|
||||
5. On success: receive blob → create object URL (or blob) for side-by-side preview.
|
||||
6. On error: show error message (e.g. from `detail`); keep local preview only.
|
||||
|
||||
Use a single file input (or drop zone); one image at a time for the “add one” flow keeps UX and error handling simple.
|
||||
|
||||
---
|
||||
|
||||
## 4. Cloudinary Upload — Server-Side
|
||||
|
||||
**Why server-side:** `CLOUDINARY_API_SECRET` must not be exposed. Upload from the client only after the server has signed the request or performed the upload.
|
||||
|
||||
**Options:**
|
||||
|
||||
- **A — Next.js API route (recommended):**
|
||||
- `POST /api/upload-image` (or under a namespaced route, e.g. `/api/admin/upload-image`) in the admin app.
|
||||
- Body: multipart with the **processed** image file (or base64/blob).
|
||||
- Handler: use `cloudinary` (Node) with `CLOUDINARY_API_SECRET` to perform a signed upload (or use the upload API with secret server-side).
|
||||
- Response: `{ url: string }` (Cloudinary `secure_url`).
|
||||
|
||||
- **B — Unsigned client upload:**
|
||||
- Create an **unsigned upload preset** in Cloudinary and use it from the client. Simpler but less control and preset is visible in client. Only use if you accept that trade-off.
|
||||
|
||||
**Recommendation:** Implement **A**. Flow: client has the processed image blob → send to Next.js route → route uploads to Cloudinary → return `secure_url` → client calls `products.addImage(productId, url, alt?, position)`.
|
||||
|
||||
**Env (already set):** `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME`, `NEXT_PUBLIC_CLOUDINARY_API_KEY`, `CLOUDINARY_API_SECRET`. Use the secret only in the API route.
|
||||
|
||||
---
|
||||
|
||||
## 5. Images Page Layout and Behaviour
|
||||
|
||||
**Route:** `apps/admin/src/app/(dashboard)/images/page.tsx`.
|
||||
|
||||
### 5.1 Structure (top to bottom)
|
||||
|
||||
1. **Title** — e.g. “Product images”.
|
||||
2. **Product search section (no overlay):**
|
||||
- **Search input** — debounced (e.g. 300 ms); query `products.search({ query, limit: 3 })` (or equivalent with limit 3).
|
||||
- **Search results** — rendered **below** the input (not a floating dropdown). Max 3 results. Each result: product name only, clickable to select. Use a list or card list; no overlay/popover so the block is part of the page flow.
|
||||
3. **Selected product gallery (only when a product is selected):**
|
||||
- **Carousel** — ShadCN Carousel of the product’s images (from `products.getByIdForAdmin(productId).images`). Each slide: image (and optional alt); top-right **delete** icon button (aria-label “Delete image”) that calls `products.deleteImage(id)` with confirmation (AlertDialog), then invalidate/refetch.
|
||||
- **Last carousel item** — “Add more” control (e.g. button or card). Click → show the upload section below.
|
||||
4. **Upload section (shown when “Add more” is clicked):**
|
||||
- **Image selection** — one zone for “click to select or drag and drop” (single file). Accept images (e.g. PNG, JPEG, WebP).
|
||||
- **Two preview containers side by side:**
|
||||
- **Local file preview** — preview of the selected file (object URL) as soon as a file is chosen.
|
||||
- **Processed image preview** — initially empty; while the processing API request is in flight show a **Skeleton**; when the API returns success, show the processed image so the user can compare.
|
||||
- **Upload / Submit button** — disabled until processed image is ready. On click: send processed image to the Next.js Cloudinary upload route → get URL → call `products.addImage(productId, url, alt?, position)`. Position = current `images.length` or `max(positions)+1`. On success: refetch product, clear upload state, optionally hide upload section or leave it open for another image.
|
||||
|
||||
### 5.2 Position and reorder
|
||||
|
||||
- **Add:** `position = product.images.length > 0 ? Math.max(...product.images.map(i => i.position)) + 1 : 0`.
|
||||
- **Reorder:** If you add drag-and-drop reorder in the carousel later, on drop compute the new order and call `products.reorderImages({ updates: images.map((img, i) => ({ id: img._id, position: i })) })`. Out of scope for MVP: optional “Reorder” mode + reorderImages call.
|
||||
|
||||
---
|
||||
|
||||
## 6. UI Components and ShadCN
|
||||
|
||||
- **Carousel:** ShadCN Carousel (Embla). Install: `npx shadcn@latest add carousel`. Use `Carousel`, `CarouselContent`, `CarouselItem`, `CarouselPrevious`, `CarouselNext`. Last item is the “Add more” tile/button.
|
||||
- **Search:** Input + debounced query; results as a static list below (no floating dropdown).
|
||||
- **Preview containers:** Divs with aspect ratio; use `next/image` only if you have a URL (e.g. Cloudinary); for local/processed blob preview use `<img src={objectUrl} />` or a small preview component.
|
||||
- **Skeleton:** ShadCN Skeleton in the processed preview area while the processing API is loading.
|
||||
- **Delete:** Icon button (e.g. Trash) with `aria-label="Delete image"`; AlertDialog for confirmation before `products.deleteImage`.
|
||||
- **Drag-and-drop zone:** A single zone (div with border/dashed) that accepts click (hidden file input) and drag/drop; one file at a time. Use native drag events or a small library; keep it simple.
|
||||
|
||||
Install any missing ShadCN components:
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add carousel button input skeleton alert-dialog
|
||||
```
|
||||
|
||||
Use relative imports; no `@/` in the admin app.
|
||||
|
||||
---
|
||||
|
||||
## 7. Environment Variables
|
||||
|
||||
| Variable | Where used | Notes |
|
||||
|----------|-------------|--------|
|
||||
| `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME` | Client (e.g. display) / server upload | Already set. |
|
||||
| `NEXT_PUBLIC_CLOUDINARY_API_KEY` | Server upload (if needed for upload API) | Already set. |
|
||||
| `CLOUDINARY_API_SECRET` | **Server only** (Next.js API route) | Already set. |
|
||||
| `NEXT_PUBLIC_IMAGE_PROCESSING_API_URL` or `IMAGE_PROCESSING_API_URL` | Client (or server proxy) | Add: base URL for background-removal API (e.g. `http://localhost:8000`). |
|
||||
|
||||
---
|
||||
|
||||
## 8. File and Component Structure
|
||||
|
||||
- `app/(dashboard)/images/page.tsx` — main page (client component: search state, selected product, upload state, Convex queries/mutations).
|
||||
- **Optional split:**
|
||||
- `components/images/ProductSearchSection.tsx` — search input + inline results list (max 3).
|
||||
- `components/images/ProductImageCarousel.tsx` — carousel + delete + “Add more” tile.
|
||||
- `components/images/ImageUploadSection.tsx` — drop/select zone, local preview, processed preview (with skeleton), submit button.
|
||||
- **API route:** `app/api/upload-image/route.ts` (or `app/api/admin/upload-image/route.ts`) — receives file, uploads to Cloudinary, returns `{ url }`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation Order
|
||||
|
||||
1. **Env** — Add Image Processing API base URL.
|
||||
2. **ShadCN** — Install carousel, skeleton, button, input, alert-dialog if not present.
|
||||
3. **Product search** — Search input, debounced `products.search` with limit 3, inline results (product name, click to select). No overlay.
|
||||
4. **Gallery** — On select, fetch `products.getByIdForAdmin(id)`; render carousel of `product.images`; last item = “Add more” button that reveals upload section.
|
||||
5. **Delete** — Delete icon per slide; AlertDialog confirm → `products.deleteImage` → refetch.
|
||||
6. **Upload section** — Drop/select zone; local preview; call Image Processing API; processed preview with skeleton; side-by-side layout.
|
||||
7. **Cloudinary** — Next.js API route: accept processed image, upload to Cloudinary (using secret server-side), return URL.
|
||||
8. **Submit** — On submit: call upload route with processed blob → get URL → `products.addImage(productId, url, alt?, position)`; position = next index; refetch product and clear upload state.
|
||||
9. **Polish** — Empty state (no product selected; no images for product); error toasts for processing/upload/Convex errors; a11y (labels, aria-labels on icon buttons).
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of Scope (This Plan)
|
||||
|
||||
- Variants.
|
||||
- Convex file storage (we use Cloudinary only).
|
||||
- Alt text editing in the UI (can pass optional `alt` to `addImage`; extend form later if needed).
|
||||
- Bulk upload (multiple files at once).
|
||||
|
||||
This plan is the single reference for implementing the images feature on the admin images route for senior engineers. Use Convex, ShadCN, and Cloudinary docs/MCP as needed for API and component details.
|
||||
|
||||
---
|
||||
|
||||
## 11. Completed Work
|
||||
|
||||
**Checklist items delivered: 3.5 (Product image upload) and 3.6 (Image gallery management).**
|
||||
|
||||
### Dependencies installed
|
||||
- `embla-carousel-react` + ShadCN `carousel` component (via `npx shadcn@latest add carousel`)
|
||||
- `cloudinary` npm package — server-side upload in Next.js API route
|
||||
- `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities` — drag-and-drop reorder
|
||||
|
||||
### Configuration
|
||||
- `apps/admin/.env.local` — added `NEXT_PUBLIC_IMAGE_PROCESSING_API_URL` (background removal API base URL)
|
||||
- `apps/admin/next.config.js` — added `images.remotePatterns` for `res.cloudinary.com`
|
||||
|
||||
### API route
|
||||
- **`src/app/api/upload-image/route.ts`** — receives multipart form data (`file`, `productId`, `position`); uploads to Cloudinary server-side using `CLOUDINARY_API_SECRET`; sets structured `public_id` and `asset_folder` for portal organisation; returns `{ url }`.
|
||||
- `public_id` pattern: `the-pet-loft/products/{productId}/main` (position 0) or `the-pet-loft/products/{productId}/gallery-{n}` (position n)
|
||||
- `asset_folder` set to `the-pet-loft/products/{productId}` for Cloudinary portal folder visibility (dynamic folder mode)
|
||||
- `overwrite: true` — re-uploading to the same position replaces the asset in-place
|
||||
|
||||
### Components
|
||||
- **`src/components/images/ProductSearchSection.tsx`** — debounced search input (300 ms); queries `products.search` with `limit: 3`; inline results list (no popover); highlights selected product; `onClear` callback resets parent state when input is cleared (X button or backspace to empty).
|
||||
- **`src/components/images/ProductImageCarousel.tsx`** — horizontal drag-and-drop image gallery using `@dnd-kit/sortable` with `horizontalListSortingStrategy`. Each image card uses the 180° rotation technique: the container is `rotate-180` and children are defined in DOM order `[delete → image → drag handle]` — counter-rotated with `rotate-180` each — so the visual order is `[drag handle → image → delete]` (top to bottom). `DragHandle` is a separate component following the dnd-kit custom drag handle pattern. On drag end, `arrayMove` updates local state immediately and `reorderImages` mutation persists new positions. AlertDialog confirmation before delete. "Add image" tile sits outside `SortableContext`, always at the end.
|
||||
- **`src/components/images/ImageUploadSection.tsx`** — drag-and-drop / click file zone; stores `originalFile` in state; auto-calls background removal API on file select; side-by-side local vs processed preview (Skeleton while loading); dual upload buttons:
|
||||
- **Upload processed** — uploads background-removed blob; enabled when processing is complete.
|
||||
- **Upload original** — uploads raw original file; enabled as soon as a file is selected.
|
||||
- Both share `uploadBlob` helper; both send `productId` + `position` to the API route; both call `products.addImage` on success.
|
||||
|
||||
### Page
|
||||
- **`src/app/(dashboard)/images/page.tsx`** — client component; manages `selectedProduct` and `showUpload` state; queries `getByIdForAdmin` only when a product is selected; computes `nextPosition` from current images; clears gallery when search is cleared.
|
||||
276
apps/admin/docs/05-variants-feature-implementation-plan.md
Normal file
276
apps/admin/docs/05-variants-feature-implementation-plan.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# Variants Feature — Implementation Plan (Admin Dashboard)
|
||||
|
||||
**Audience:** Senior software engineers
|
||||
**Scope:** Variants route only — product search (shared with images page), variants table per product, create/edit dialogs, preview dialog. No images or other product management in this plan.
|
||||
**References:** Convex MCP, ShadCN UI MCP, `.agent/skills/shadcn-ui`, `convex/schema.ts` (productVariants), `@repo/utils` (dollarsToCents, centsToDollars), admin CLAUDE.md and admin-dashboard-ui rule.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Implement the Variant Management feature for the admin dashboard (checklist items 3.7, 3.8, 3.9), limited to:
|
||||
|
||||
- **Variants page** (`/variant`) — title; shared product search section; on product select, fetch its variants and show them in a table with create-variant button (dialog), edit action (dialog), preview (dialog on variant name click), and actions column.
|
||||
- **Shared component** — Product search (debounced, inline results, max 3, click to select) is implemented once in `apps/admin/src/components/shared` and reused on both the **Images** page and the **Variants** page.
|
||||
|
||||
All UI must use **ShadCN UI only**. Data is served by existing Convex `products.*` APIs with backend extensions where noted below.
|
||||
|
||||
---
|
||||
|
||||
## 2. Shared Component: Product Search Section
|
||||
|
||||
**Location:** `apps/admin/src/components/shared/ProductSearchSection.tsx` (or similar name).
|
||||
|
||||
**Behaviour (align with Images plan):**
|
||||
|
||||
- **Search input** — debounced (e.g. 300 ms). Query `products.search({ query, limit: 3 })`.
|
||||
- **Results** — rendered **below** the input (no floating overlay). Max 3 items. Each result: **product name only**, clickable to select.
|
||||
- **API:** Controlled or uncontrolled: either accept `value` / `onChange` (selected product id + product doc) from the parent, or expose a callback `onProductSelect(product)` so both Images and Variants pages can react (e.g. set selected product id and fetch images/variants).
|
||||
|
||||
**Contract:**
|
||||
|
||||
- Props: at minimum `onProductSelect: (product: { _id: Id<"products">; name: string; ... }) => void`. Optional: `placeholder`, `debounceMs`, `maxResults`.
|
||||
- Use relative imports. No `@/` in admin app.
|
||||
|
||||
**Usage:**
|
||||
|
||||
- **Images page** — `<ProductSearchSection onProductSelect={...} />`; on select, fetch product (e.g. `getByIdForAdmin`) and show image gallery.
|
||||
- **Variants page** — same; on select, fetch product and show variants table.
|
||||
|
||||
Implement this component once; refactor the Images page to use it when both features are in place (or implement it as shared from the start on the Variants page and then wire Images to it).
|
||||
|
||||
---
|
||||
|
||||
## 3. Backend (Convex) — Required Changes
|
||||
|
||||
### 3.1 Current API
|
||||
|
||||
| API | Purpose |
|
||||
|-----|---------|
|
||||
| `products.search` | Product search; use with limit 3 for shared search section. |
|
||||
| `products.getByIdForAdmin` | Returns product with `variants` (and images, category). Use to load variants for the selected product. |
|
||||
| `products.addVariant` | Create variant. See args below. |
|
||||
| `products.updateVariant` | Update variant. **Currently** only accepts `id`, `price?`, `compareAtPrice?`, `stockQuantity?`, `isActive?`. |
|
||||
| `products.deleteVariant` | Delete (or soft-delete if variant appears in orderItems). |
|
||||
|
||||
### 3.2 Extend `products.addVariant`
|
||||
|
||||
**Current args:** `productId`, `name`, `sku`, `price`, `compareAtPrice?`, `stockQuantity`, `attributes?`, `isActive`, `weight?`, `weightUnit?`.
|
||||
Schema also has: `length?`, `width?`, `height?`, `dimensionUnit?`.
|
||||
|
||||
**Required:** Add optional dimension fields so the create form can persist them when the user fills dimensions.
|
||||
|
||||
- `length: v.optional(v.number())`
|
||||
- `width: v.optional(v.number())`
|
||||
- `height: v.optional(v.number())`
|
||||
- `dimensionUnit: v.optional(v.union(v.literal("cm"), v.literal("in")))`
|
||||
|
||||
In the handler, pass these through to `ctx.db.insert("productVariants", { ... })` only when defined. Ensure `weight` / `weightUnit` remain required in practice (addVariant already defaults `weight ?? 0`, `weightUnit ?? "g"`; schema allows it).
|
||||
|
||||
### 3.3 Extend `products.updateVariant`
|
||||
|
||||
**Current args:** `id`, `price?`, `compareAtPrice?`, `stockQuantity?`, `isActive?`.
|
||||
|
||||
For a full **edit variant** form, the admin must be able to change name, sku, weight, attributes, dimensions, etc. Extend the mutation to accept all editable fields:
|
||||
|
||||
- `name: v.optional(v.string())`
|
||||
- `sku: v.optional(v.string())`
|
||||
- `price: v.optional(v.number())`
|
||||
- `compareAtPrice: v.optional(v.number())`
|
||||
- `stockQuantity: v.optional(v.number())`
|
||||
- `isActive: v.optional(v.boolean())`
|
||||
- `weight: v.optional(v.number())`
|
||||
- `weightUnit: v.optional(v.union(v.literal("g"), v.literal("kg"), v.literal("lb"), v.literal("oz")))`
|
||||
- `attributes: v.optional(v.object({ size: v.optional(v.string()), flavor: v.optional(v.string()), color: v.optional(v.string()) }))`
|
||||
- `length: v.optional(v.number())`, `width: v.optional(v.number())`, `height: v.optional(v.number())`
|
||||
- `dimensionUnit: v.optional(v.union(v.literal("cm"), v.literal("in")))`
|
||||
|
||||
Handler: patch only fields that are present in the updates object. If you introduce `sku` updates, consider enforcing uniqueness (e.g. check `by_sku` for another variant with same sku and different id).
|
||||
|
||||
---
|
||||
|
||||
## 4. Variants Page Layout and Behaviour
|
||||
|
||||
**Route:** `apps/admin/src/app/(dashboard)/variant/page.tsx`.
|
||||
|
||||
### 4.1 Structure (top to bottom)
|
||||
|
||||
1. **Title** — e.g. “Variants”.
|
||||
2. **Product search section** — Use the shared `ProductSearchSection`. On select: store `selectedProductId` (and optionally the product doc); trigger fetch of product with variants (`products.getByIdForAdmin(selectedProductId)`).
|
||||
3. **Toolbar above table (only when a product is selected):**
|
||||
- Right-aligned: **Create variant** button. Click opens a **dialog** containing the create-variant form (product is fixed; `productId` is the selected product).
|
||||
4. **Variants table (only when a product is selected):**
|
||||
- Data: `product.variants` from `getByIdForAdmin`. No separate “list variants” query.
|
||||
- Columns: show only what an admin needs at first sight (see table below). **Variant name** cell is clickable and opens a **preview dialog** with full variant data (read-only).
|
||||
- **Actions column (last):** Edit (opens edit dialog), Delete (AlertDialog then `products.deleteVariant`). Use DropdownMenu or icon buttons with aria-labels.
|
||||
5. **Create variant dialog** — Form in a ShadCN Dialog. On success: call `products.addVariant`, invalidate/refetch product, close dialog, toast.
|
||||
6. **Edit variant dialog** — Same form fields, pre-populated from the selected variant. On success: call `products.updateVariant`, invalidate/refetch, close dialog, toast.
|
||||
7. **Preview dialog** — Triggered by clicking the variant name. Read-only view of all variant fields (name, sku, price, compareAtPrice, stock, isActive, weight, weightUnit, attributes, dimensions). Use ShadCN Dialog; no form.
|
||||
|
||||
### 4.2 Table columns (first sight)
|
||||
|
||||
| Column | Notes |
|
||||
|---------------|--------|
|
||||
| **Name** | Clickable; opens preview dialog. |
|
||||
| **SKU** | Unique identifier. |
|
||||
| **Price** | Display using `formatPrice` from `@repo/utils` (cents → currency string). |
|
||||
| **Compare at**| Optional; show “—” if empty. |
|
||||
| **Stock** | `stockQuantity`. |
|
||||
| **Status** | Badge: Active / Inactive from `isActive`. |
|
||||
| **Weight** | e.g. `{weight} {weightUnit}`. |
|
||||
| **Actions** | Edit, Delete (dropdown or icons). |
|
||||
|
||||
Avoid cluttering the table with attributes/dimensions; those are in the preview dialog.
|
||||
|
||||
### 4.3 Empty and loading states
|
||||
|
||||
- No product selected: show only title and search; optional short hint (“Search and select a product to manage variants”).
|
||||
- Product selected, variants loading: table skeleton (same column count, e.g. 5–10 skeleton rows).
|
||||
- Product selected, no variants: empty state message and prominent Create variant button.
|
||||
|
||||
---
|
||||
|
||||
## 5. Create / Edit Form — Fields and Validation
|
||||
|
||||
### 5.1 Required vs optional (schema-aligned)
|
||||
|
||||
| Field | Required | Form / mutation notes |
|
||||
|-------------------|----------|------------------------|
|
||||
| `productId` | Yes | Set by system; not in form (selected product). |
|
||||
| `name` | Yes | Text. |
|
||||
| `sku` | Yes | Text; unique (backend or zod + async check). |
|
||||
| `price` | Yes | **Display in dollars**; convert to cents on submit with `dollarsToCents` from `@repo/utils`. |
|
||||
| `stockQuantity` | Yes | Number; default 0 on create. |
|
||||
| `isActive` | Yes | Boolean; default true on create. |
|
||||
| `weight` | Yes | Number. |
|
||||
| `weightUnit` | Yes | Select: `g`, `kg`, `lb`, `oz`. **Default for UK context: `kg`.** |
|
||||
| `compareAtPrice` | Optional | Show only when “On Sale” toggle is on. Cents in DB; form in dollars. |
|
||||
| `attributes` | Optional | size, flavor, color — only if product uses them. |
|
||||
| `length` | Optional | Only if dimensions are used. |
|
||||
| `width` | Optional | Same. |
|
||||
| `height` | Optional | Same. |
|
||||
| `dimensionUnit` | Optional | **Only show when at least one of length/width/height is set.** Default `cm`. |
|
||||
|
||||
### 5.2 Minimum viable variant (create)
|
||||
|
||||
```
|
||||
name + sku + price (dollars → cents) + stockQuantity + isActive + weight + weightUnit
|
||||
```
|
||||
|
||||
Default `stockQuantity: 0`, `isActive: true` on create.
|
||||
|
||||
### 5.3 Form behaviour
|
||||
|
||||
- **Price:** Single field in dollars (e.g. `19.99`). On submit: `dollarsToCents(value)` before sending to Convex. Never ask the admin to type cents.
|
||||
- **Compare at price:** Only render this field when an “On Sale” (or “Has compare-at price”) toggle is true. Keeps the form simple when not on sale.
|
||||
- **Dimension unit:** Show **only** when at least one of `length`, `width`, `height` has a value. Use `watch` on those three fields (react-hook-form); default dimension unit `cm`.
|
||||
- **Dimensions group:** If one of length/width/height is filled, require all three (and dimension unit). Add a **zod refinement**: e.g. “If any dimension is set, all three and dimensionUnit must be set.”
|
||||
- **Weight unit:** Default `kg` for UK pet-store context (override default in form `defaultValues`).
|
||||
|
||||
### 5.4 Validation (zod)
|
||||
|
||||
- Required: name (non-empty), sku (non-empty), price (positive number), stockQuantity (integer ≥ 0), isActive (boolean), weight (≥ 0), weightUnit (enum).
|
||||
- Optional: compareAtPrice (positive number when “On Sale”), attributes (object with optional size, flavor, color), length/width/height (positive numbers), dimensionUnit (when dimensions present).
|
||||
- Refinement: (length != null || width != null || height != null) ⇒ all three and dimensionUnit present.
|
||||
|
||||
Use `react-hook-form` with `zodResolver` and ShadCN Form/Input/Select/Switch components. Form lives inside the Create and Edit dialogs.
|
||||
|
||||
---
|
||||
|
||||
## 6. ShadCN Components
|
||||
|
||||
Ensure these are available (install via CLI if not):
|
||||
|
||||
- Table, Button, Input, Select, Label, Form (with FormField, FormItem, FormControl, FormMessage), Dialog, AlertDialog, Badge, Skeleton, DropdownMenu.
|
||||
|
||||
```bash
|
||||
npx shadcn@latest add table button input select form dialog alert-dialog badge skeleton dropdown-menu label
|
||||
```
|
||||
|
||||
Use relative imports. Icon buttons (e.g. Edit, Delete) must have `aria-label`.
|
||||
|
||||
---
|
||||
|
||||
## 7. File and Component Structure
|
||||
|
||||
- `app/(dashboard)/variant/page.tsx` — main page (client: selected product, variants data from `getByIdForAdmin`, create/edit dialog open state).
|
||||
- `components/shared/ProductSearchSection.tsx` — shared product search (used by `/images` and `/variant`).
|
||||
- Optional: `components/variants/VariantsTable.tsx` — table + name-cell preview trigger + actions column.
|
||||
- Optional: `components/variants/VariantForm.tsx` — shared form for create and edit (props: `defaultValues`, `onSubmit`, `productId` for create).
|
||||
- Optional: `components/variants/VariantPreviewDialog.tsx` — read-only full variant details.
|
||||
- Optional: `components/variants/CreateVariantDialog.tsx` and `EditVariantDialog.tsx` — dialogs that wrap the form and call addVariant/updateVariant.
|
||||
|
||||
---
|
||||
|
||||
## 8. Implementation Order
|
||||
|
||||
1. **Shared component** — Implement `ProductSearchSection` in `components/shared`. Wire it on the Variants page with `onProductSelect`; optionally refactor Images page to use it.
|
||||
2. **Backend** — Extend `addVariant` with optional length, width, height, dimensionUnit. Extend `updateVariant` with full set of editable fields (name, sku, price, compareAtPrice, stockQuantity, isActive, weight, weightUnit, attributes, dimensions). Ensure SKU uniqueness on update if needed.
|
||||
3. **Variants page shell** — Title, ProductSearchSection, state for selected product. When product selected, fetch `products.getByIdForAdmin(productId)` and store product (with variants).
|
||||
4. **Table** — Render variants in ShadCN Table; columns: name (clickable), sku, price (formatPrice), compareAtPrice, stock, status (Badge), weight + unit, actions. Skeleton when loading; empty state when no variants.
|
||||
5. **Preview dialog** — Variant name click opens dialog; display all variant fields read-only.
|
||||
6. **Create variant dialog** — Button above table; dialog with form (name, sku, price in dollars, stock, isActive, weight, weightUnit [default kg], optional compare-at when “On Sale”, optional attributes, optional dimensions with refinement). Submit → dollarsToCents(price) (and compareAtPrice if present) → `products.addVariant` → refetch, close, toast.
|
||||
7. **Edit variant dialog** — Same form pre-filled; submit → `products.updateVariant` with changed fields; refetch, close, toast.
|
||||
8. **Delete** — Actions column: delete with AlertDialog confirmation → `products.deleteVariant` → refetch, toast.
|
||||
9. **Polish** — a11y (labels, aria-labels), error toasts, optional inline stock/price edit (if you want to keep 3.8/3.9 minimal without opening edit dialog).
|
||||
|
||||
---
|
||||
|
||||
## 9. Out of Scope (This Plan)
|
||||
|
||||
- Images route changes (only reuse of ProductSearchSection).
|
||||
- Product create/edit or other product management.
|
||||
- Bulk variant operations.
|
||||
- Inline table editing (optional future: edit stock/price in cell; for now Edit opens the full form in a dialog).
|
||||
|
||||
This plan is the single reference for implementing the variants feature on the admin variant route for senior engineers. Use Convex and ShadCN MCP/skills for API and component details as needed.
|
||||
|
||||
---
|
||||
|
||||
## 10. Completed Implementation
|
||||
|
||||
### Backend (`convex/products.ts`)
|
||||
- Extended `addVariant` with optional dimension fields (`length`, `width`, `height`, `dimensionUnit`) and SKU uniqueness check via `by_sku` index.
|
||||
- Extended `updateVariant` from 4 fields to full set: `name`, `sku`, `price`, `compareAtPrice`, `stockQuantity`, `isActive`, `weight`, `weightUnit`, `attributes`, dimensions. SKU uniqueness enforced on change.
|
||||
- Updated `getByIdForAdmin` to fetch **all** variants (not just active) using `by_product` index — admins need to see inactive variants.
|
||||
|
||||
### Shared Component
|
||||
- `components/shared/ProductSearchSection.tsx` — moved from `components/images/` to `shared/`, fixed `@/` aliases to relative imports.
|
||||
- `app/(dashboard)/images/page.tsx` — updated import to shared location.
|
||||
- `lib/constants/app.constants.ts` — fixed nav link `/variants` → `/variant`.
|
||||
|
||||
### Variants Page (`app/(dashboard)/variant/page.tsx`)
|
||||
- Title, product search section, `selectedProduct` state, `useQuery(getByIdForAdmin)` skipped until product selected.
|
||||
- Loading skeleton (6 rows), empty state hint, product name + variant count header.
|
||||
- Create variant button in toolbar (hidden while loading).
|
||||
- All dialog open states managed in page: `createOpen`, `editVariant`, `previewVariant`.
|
||||
|
||||
### `components/variants/VariantsTable.tsx`
|
||||
- 8 columns: Name (clickable → preview), SKU (monospace), Price (`formatPrice`), Compare at, Stock, Status badge, Weight, Actions.
|
||||
- `VariantActionsMenu` per row: Edit, Activate/Deactivate (toggles `isActive` via `updateVariant`), Delete (AlertDialog confirmation).
|
||||
- `VariantsTableSkeleton` — 6 skeleton rows for loading state.
|
||||
- Empty state — "No variants yet" spanning all columns.
|
||||
|
||||
### `components/variants/VariantPreviewDialog.tsx`
|
||||
- Read-only dialog with `ScrollArea`. Sections: Core (SKU, status badge), Pricing, Inventory, Shipping (weight + dimensions if set), Attributes (size/flavor/color if set).
|
||||
|
||||
### `components/variants/VariantForm.tsx`
|
||||
- Zod schema with `superRefine`: "On Sale" requires `compareAtPrice`; any dimension requires all three + `dimensionUnit`.
|
||||
- `optionalPositiveNum` preprocessor (cast to `z.ZodType<number | undefined>`) handles empty string → `undefined` for optional number inputs.
|
||||
- Required fields: `name`, `sku`, `price` (> 0), `stockQuantity` (≥ 0), `isActive`, `weight` (> 0), `weightUnit`.
|
||||
- Auto-SKU generation from `brand` + `productName` + variant attributes + weight using `generateSku` from `@repo/utils`. Only fires while `skuManuallyEdited` is false. Typing in SKU field locks it; resets to auto in create mode on `defaultValues` change.
|
||||
- Edit mode: `skuManuallyEdited` starts `true` — existing SKU never overwritten.
|
||||
- `variantToFormValues()` helper converts cents → dollars for price fields.
|
||||
- `CREATE_DEFAULTS`: `isActive: true`, `weightUnit: "kg"`, `stockQuantity: 0`.
|
||||
|
||||
### `components/variants/CreateVariantDialog.tsx`
|
||||
- Wraps `VariantForm` in Dialog + ScrollArea. Submit calls `products.addVariant` with `dollarsToCents` applied to price fields. Passes `productName` and `brand` for SKU generation.
|
||||
|
||||
### `components/variants/EditVariantDialog.tsx`
|
||||
- Pre-fills form via `variantToFormValues(variant)`. Submit calls `products.updateVariant`. Passes `productName` and `brand`.
|
||||
|
||||
### `packages/utils/src/index.ts`
|
||||
- Added `generateSku(brand, productName, attributes?, weight?, weightUnit?)` — cleans each word to 4 uppercase chars, joins with `-`.
|
||||
|
||||
### `app/layout.tsx`
|
||||
- Added `<Toaster richColors position="bottom-right" />` (sonner). Installed `sonner` package in admin workspace.
|
||||
@@ -0,0 +1,236 @@
|
||||
# Order Processing Backend — Phased Implementation Plan
|
||||
|
||||
**Audience:** Senior software engineers
|
||||
**Scope:** Backend only (Convex, Stripe, Shippo). No UI.
|
||||
**Source:** [Admin Order Processing User Stories](docs/project-documentation/storefront/admin-order-processing-user-stories.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive summary
|
||||
|
||||
This plan breaks the order-processing backend into four phases:
|
||||
|
||||
| Phase | Focus | Key deliverables |
|
||||
|-------|--------|-------------------|
|
||||
| **1** | Label creation | Resolve rate from shipment, POST Shippo transaction, persist label data, status → `processing`, timeline |
|
||||
| **2** | Shippo Track Updated webhook | HTTP route, verify & parse, update order + timeline, set `delivered` on DELIVERED |
|
||||
| **3** | Return/cancel only when confirmed | Enforce `status === "confirmed"` for customer return/cancel; optional schema for `labelUrl` |
|
||||
| **4** | Refund idempotency & polish | Idempotent Stripe refund, optional tracking refresh action, tests |
|
||||
|
||||
**Already in place (no work in this plan):**
|
||||
|
||||
- **Schema:** `orders` (including `shippoShipmentId`, `shippingServiceCode`, `carrier`, `trackingNumber`, `trackingUrl`, `estimatedDelivery`, `actualDelivery`, `shippedAt`, `returnRequestedAt`, `returnReceivedAt`); `orderTimelineEvents` with `by_order` and `by_order_and_created_at`.
|
||||
- **Model:** `recordOrderTimelineEvent` in `convex/model/orders.ts`.
|
||||
- **Queries:** `orders.getTimeline` (timeline by order).
|
||||
- **Orders:** `fulfillFromCheckout` (stores Shippo shipment ID and rate identifiers); `updateStatus` (admin) with timeline; `cancel` (customer, only when allowed) with timeline and stock restore; `requestReturn`, `markReturnReceived`, `getOrderForRefund`, `applyRefund`.
|
||||
- **Refunds:** `returnActions.issueRefund` (admin, Stripe refund + `applyRefund`).
|
||||
- **HTTP:** Clerk and Stripe webhooks in `convex/http.ts`.
|
||||
|
||||
**Resources:** Convex MCP, Stripe MCP, Shippo MCP (e.g. `shipments-get`, `transactions-create`, `tracking-status-get`, `webhooks-create`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Phase 1 — Label creation (US-ADM-1, US-ADM-2)
|
||||
|
||||
**Goal:** Admin can create a shipping label for an order in status `confirmed`. On success, persist label data, set status to `processing` (or `shipped`), and record a timeline event. Enforce valid transitions and prevent duplicate labels.
|
||||
|
||||
### 2.1 Tasks
|
||||
|
||||
1. **Schema (optional)**
|
||||
- Add `labelUrl: v.optional(v.string())` to `orders` if the team wants to store the label PDF/PNG URL from Shippo.
|
||||
- If not stored, admin can open `tracking_url_provider` or use Shippo dashboard for the label.
|
||||
|
||||
2. **Shippo: GET shipment and resolve rate**
|
||||
- In `convex/model/shippo.ts` (or a new internal helper used only by the label action):
|
||||
- Add a function that calls Shippo **GET** `https://api.goshippo.com/shipments/{order.shippoShipmentId}` (or use Shippo MCP `shipments-get` from an action).
|
||||
- From the response `rates` array, find the rate where `rate.servicelevel.token === order.shippingServiceCode` and `rate.provider === order.carrier`.
|
||||
- Return that rate’s `object_id`.
|
||||
- Handle: shipment not found, no matching rate, or multiple matches (pick first or fail explicitly).
|
||||
|
||||
3. **Shippo: Create transaction (purchase label)**
|
||||
- Add a Convex **action** (e.g. in `convex/fulfillmentActions.ts` or `convex/orders.ts` with `"use node";` in a separate file if needed) that:
|
||||
- Takes `orderId: v.id("orders")`.
|
||||
- Runs with admin auth (e.g. resolve user via identity, then `requireAdmin`-style check via internal query).
|
||||
- Loads order (internal query); validates `order.status === "confirmed"` and that the order has no `trackingNumber` yet (no label already created).
|
||||
- Calls the new “resolve rate” helper to get the rate object ID from `order.shippoShipmentId`, `order.shippingServiceCode`, `order.carrier`.
|
||||
- Calls Shippo **POST** `/transactions` with `{ rate: rateObjectId }` (and `async: true` for international if applicable).
|
||||
- If Shippo returns async (e.g. status `QUEUED`), poll **GET** transaction until `SUCCESS` or `ERROR` (or schedule a follow-up action to poll).
|
||||
- On success: extract `tracking_number`, `tracking_url_provider`, and optionally `label_url` from the transaction response.
|
||||
|
||||
4. **Persist label and update order**
|
||||
- From the same action, call an **internal mutation** that:
|
||||
- Receives `orderId`, `trackingNumber`, `trackingUrl` (from `tracking_url_provider`), optional `labelUrl`, and optional `eta` if Shippo provides it.
|
||||
- Validates again that order is `confirmed` and has no existing `trackingNumber` (idempotency / duplicate-label guard).
|
||||
- Patches order: `trackingNumber`, `trackingUrl`, `labelUrl` (if schema added), `estimatedDelivery` (if provided), `shippedAt: Date.now()`, `status: "processing"` (or `"shipped"` per team decision), `updatedAt: Date.now()`.
|
||||
- Calls `recordOrderTimelineEvent` with `eventType: "label_created"`, `source: "admin"`, `fromStatus: "confirmed"`, `toStatus: "processing"` (or `"shipped"`), and optional `payload` (e.g. JSON with tracking number and carrier).
|
||||
- On Shippo failure (rate expired, address issue, etc.): return a structured error (e.g. `{ success: false, code: "RATE_EXPIRED" | "SHIPPO_ERROR", message: string }`) so the client can show a clear message.
|
||||
|
||||
5. **Public API**
|
||||
- Expose one public (or admin-only) **action** that orchestrates: resolve rate → create transaction → persist + timeline.
|
||||
- Ensure only admins can call it (e.g. resolve user and check role in action, or call from a mutation that already enforces admin).
|
||||
|
||||
### 2.2 Acceptance (backend)
|
||||
|
||||
- Only orders in status `confirmed` and without an existing `trackingNumber` can receive a label.
|
||||
- After a successful label creation, order has `trackingNumber`, `trackingUrl`, `shippedAt`, and status `processing` (or `shipped`); one `label_created` timeline event exists.
|
||||
- Duplicate label creation for the same order is rejected.
|
||||
- Rate resolution uses `shippoShipmentId` + `shippingServiceCode` + `carrier`; expired or missing rate returns a clear error.
|
||||
|
||||
### 2.3 Dependencies and references
|
||||
|
||||
- Shippo: [Transactions](https://docs.goshippo.com/docs/guides_general/transactions/) (POST with rate ID).
|
||||
- Shippo MCP: `shipments-get` (ShipmentId = `order.shippoShipmentId`), `transactions-create` (request `{ rate: rateObjectId }`).
|
||||
- Existing: `convex/model/shippo.ts` (GET shipment not present today; add or call from action via fetch/MCP).
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 2 — Shippo Track Updated webhook (US-ADM-3)
|
||||
|
||||
**Goal:** Receive Shippo “Track Updated” webhooks, verify and parse payload, update the order (tracking status, ETA, and on DELIVERED set `status: "delivered"`, `actualDelivery`), and append a `tracking_update` event to `orderTimelineEvents`. No manual refresh required for live tracking.
|
||||
|
||||
### 3.1 Tasks
|
||||
|
||||
1. **HTTP route**
|
||||
- In `convex/http.ts`, add a route, e.g. `POST /shippo/webhook`.
|
||||
- Handler: `httpAction` that reads body, optionally verifies Shippo signature (if Shippo supports it; check [Shippo webhooks](https://docs.goshippo.com/docs/tracking/tracking)), then calls an internal action with the raw body (or parsed JSON).
|
||||
|
||||
2. **Webhook handler action**
|
||||
- Implement an **internal action** (e.g. `internal.fulfillmentActions.handleShippoTrackUpdated` or `internal.shippoWebhook.handleTrackUpdated`) that:
|
||||
- Parses the JSON payload (carrier, tracking_number, tracking_status, status_details, location, eta, etc.).
|
||||
- Finds the order: query by `trackingNumber` + `carrier`. Add an index on `orders` e.g. `by_tracking_number_and_carrier: ["trackingNumber", "carrier"]` for efficient lookup (only orders with a label have these set).
|
||||
- If no order found, return 200 anyway (avoid retries for bad data) and log.
|
||||
- If order found: call an **internal mutation** that:
|
||||
- Updates the order: set latest tracking state (e.g. store a minimal `trackingStatus` / `trackingStatusDetails` on `orders` if desired for display; otherwise derive from timeline).
|
||||
- Sets `estimatedDelivery` from webhook `eta` if provided.
|
||||
- If tracking status is **DELIVERED**: set `order.status = "delivered"`, `actualDelivery = Date.now()`, `updatedAt = Date.now()`.
|
||||
- Inserts one `orderTimelineEvents` row: `eventType: "tracking_update"`, `source: "shippo_webhook"`, `payload: JSON.stringify(webhookPayload)`, `createdAt: Date.now()`.
|
||||
|
||||
3. **Idempotency / duplicates**
|
||||
- Shippo may send duplicate events. Options: (a) store last processed `tracking_status` + timestamp on the order and skip if same status already applied; (b) or accept duplicate timeline events and treat UI as “last event wins” for status. Prefer (a) if you want a clean timeline.
|
||||
|
||||
4. **Shippo webhook registration**
|
||||
- Document or script: register in Shippo dashboard (or via Shippo MCP `webhooks-create`) a webhook with `event: "track_updated"` and URL `https://<deployment>.convex.site/shippo/webhook`. Use production URL and live API key for live tracking.
|
||||
|
||||
### 3.2 Optional: “Refresh tracking” action
|
||||
|
||||
- Add an **action** (admin-only) that takes `orderId`, loads order, then calls Shippo **GET** tracking (e.g. `tracks/{carrier}/{tracking_number}` or Shippo MCP `tracking-status-get`) and then updates the order + appends a `tracking_update` timeline event from the response. Use for debugging or one-off backfills; primary source remains the webhook.
|
||||
|
||||
### 3.3 Acceptance (backend)
|
||||
|
||||
- POST to `/shippo/webhook` with a valid Track Updated payload updates the correct order and appends a `tracking_update` event.
|
||||
- When status is DELIVERED, order `status` becomes `delivered` and `actualDelivery` is set.
|
||||
- Unknown tracking numbers or missing orders do not cause 5xx (return 200 and log).
|
||||
|
||||
### 3.4 Dependencies and references
|
||||
|
||||
- Shippo: [Track live shipments](https://docs.goshippo.com/docs/tracking/tracking), webhook payload shape.
|
||||
- Shippo MCP: `webhooks-create` (event `track_updated`, url = Convex HTTP URL); `tracking-status-get` for optional refresh.
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 3 — Return / cancel only when confirmed (US-ADM-4)
|
||||
|
||||
**Goal:** Enforce in the backend that customer-initiated return and cancel are allowed **only** when `order.status === "confirmed"`. Align `canCustomerRequestReturn` (or equivalent) with this rule so that once the order moves to `processing`, `shipped`, or `delivered`, return/cancel from the storefront flow is rejected.
|
||||
|
||||
### 4.1 Tasks
|
||||
|
||||
1. **Cancel (already aligned)**
|
||||
- `orders.cancel` already uses `canCustomerCancel(order)` which allows only `confirmed`. No change required unless you want to explicitly document or add a test that only `confirmed` is allowed.
|
||||
|
||||
2. **Return request**
|
||||
- Today `canCustomerRequestReturn` allows return only when `order.status === "delivered"`. Per user stories, **return is only allowed while status is confirmed**.
|
||||
- Update `canCustomerRequestReturn` in `convex/model/orders.ts` so that it returns `allowed: true` only when `order.status === "confirmed"` (and optionally not already cancelled/refunded/return requested).
|
||||
- Adjust `orders.requestReturn` so that it uses this updated helper and rejects when not allowed.
|
||||
- If the product decision is “return when confirmed = cancel”, you may keep a single cancel flow and treat “return” as cancel when confirmed; otherwise keep `requestReturn` for a distinct “return requested” state that stays in `confirmed` until admin processes it.
|
||||
|
||||
3. **Validation in mutations**
|
||||
- Ensure any customer-facing mutation that cancels or requests a return checks the same rule: `status === "confirmed"`. Add an explicit guard at the start of `cancel` and `requestReturn` (e.g. “only confirmed orders can be cancelled/returned”) so the contract is clear.
|
||||
|
||||
4. **Timeline**
|
||||
- When a return is requested (while confirmed), keep recording the existing `return_requested` (or equivalent) timeline event with `source: "customer_return"`.
|
||||
|
||||
### 4.2 Acceptance (backend)
|
||||
|
||||
- Customer cancel and customer return request succeed only when `order.status === "confirmed"`.
|
||||
- For `processing`, `shipped`, or `delivered`, both flows return a clear error (e.g. “Return/cancel not available; contact support”).
|
||||
|
||||
---
|
||||
|
||||
## 5. Phase 4 — Refund idempotency and polish (US-ADM-5)
|
||||
|
||||
**Goal:** Make refunds idempotent (no double Stripe refund if admin clicks twice), and add optional tracking refresh and tests.
|
||||
|
||||
### 5.1 Tasks
|
||||
|
||||
1. **Idempotent Stripe refund**
|
||||
- In `returnActions.issueRefund` (or equivalent): before calling `stripe.refunds.create`, list existing refunds for the payment intent (e.g. `stripe.refunds.list({ payment_intent: order.stripePaymentIntentId })`).
|
||||
- If a refund already exists for the full amount (or the intended amount), skip creating a new one and still call `internal.orders.applyRefund` only if the order is not yet `refunded` (so order and payment status are updated once).
|
||||
- If partial refunds are in scope, define the rule (e.g. “only one full refund” or “sum of refunds < total”) and implement accordingly.
|
||||
|
||||
2. **Order guard in applyRefund**
|
||||
- In `orders.applyRefund`, at the start: if `order.paymentStatus === "refunded"` (and optionally `order.status === "refunded"`), skip patching and timeline (idempotent). This protects against double application when the action is called twice.
|
||||
|
||||
3. **Optional: Tracking refresh action**
|
||||
- Implement the optional “Refresh tracking” action described in Phase 2 (GET Shippo tracking by carrier + tracking number, update order and append one `tracking_update` event). Secure with admin-only check.
|
||||
|
||||
4. **Tests**
|
||||
- Unit/integration tests for: (a) label creation — only `confirmed`, no duplicate label; (b) webhook handler — find order by tracking + carrier, set delivered on DELIVERED; (c) return/cancel only when confirmed; (d) refund idempotency (second call does not create a second Stripe refund, order updated once).
|
||||
|
||||
### 5.2 Acceptance (backend)
|
||||
|
||||
- Issuing refund twice for the same order does not create two Stripe refunds and does not double-restore stock or double-write timeline.
|
||||
- Refund flow is documented (full vs partial, idempotency rule).
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation order and dependencies
|
||||
|
||||
```
|
||||
Phase 1 (Label creation) → no dependency on Phase 2/3/4
|
||||
Phase 2 (Track webhook) → depends on orders having trackingNumber (from Phase 1 or manual)
|
||||
Phase 3 (Return when confirmed) → independent; can run in parallel with 1 or 2
|
||||
Phase 4 (Refund idempotency) → depends on existing refund flow; can run after or in parallel with 3
|
||||
```
|
||||
|
||||
Recommended sequence: **Phase 1 → Phase 2 → Phase 3 → Phase 4**, with Phase 3 and 4 swappable.
|
||||
|
||||
---
|
||||
|
||||
## 7. File and module checklist
|
||||
|
||||
| Area | File / module | Changes |
|
||||
|------|----------------|---------|
|
||||
| Schema | `convex/schema.ts` | Optional: `orders.labelUrl`; optional: `orders.trackingStatus` / `trackingStatusDetails`; index `by_tracking_number_and_carrier` on `["trackingNumber", "carrier"]` for webhook lookup. |
|
||||
| Model | `convex/model/orders.ts` | `canCustomerRequestReturn`: allow only `confirmed`; keep `recordOrderTimelineEvent`. |
|
||||
| Model | `convex/model/shippo.ts` | New: get shipment by ID; resolve rate by `shippingServiceCode` + `carrier`. |
|
||||
| Orders | `convex/orders.ts` | Optional: internal mutation for “apply label data + status + timeline”; guards in `requestReturn` / cancel. |
|
||||
| Actions | New or existing (e.g. `convex/fulfillmentActions.ts`) | Label flow: resolve rate → POST transaction → internal mutation; optional: refresh tracking action. |
|
||||
| Refunds | `convex/returnActions.ts` | Idempotent refund: list refunds, then create or skip; call `applyRefund` only when order not already refunded. |
|
||||
| Refunds | `convex/orders.ts` (`applyRefund`) | Idempotent: skip if already `paymentStatus === "refunded"`. |
|
||||
| HTTP | `convex/http.ts` | New route `POST /shippo/webhook` → internal action. |
|
||||
| Webhook handler | New or existing (e.g. `convex/shippoWebhook.ts`) | Parse Track Updated, find order, internal mutation to update order + insert `tracking_update` event; on DELIVERED set `delivered` and `actualDelivery`. |
|
||||
| Tests | `convex/orders.test.ts`, `convex/fulfillmentActions.test.ts`, etc. | Label creation rules, webhook handler, return/cancel guards, refund idempotency. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Environment and configuration
|
||||
|
||||
- **Convex:** No new env vars required for core flow.
|
||||
- **Shippo:** `SHIPPO_API_KEY` already used; ensure production webhook URL and (if applicable) webhook secret are configured for Track Updated.
|
||||
- **Stripe:** Existing keys; no new config for refunds.
|
||||
|
||||
---
|
||||
|
||||
## 9. Story-to-phase mapping
|
||||
|
||||
| Story | Phase | Backend work |
|
||||
|-------|--------|----------------|
|
||||
| US-ADM-1 Create shipping label | 1 | Resolve rate, POST transaction, persist label, status, timeline |
|
||||
| US-ADM-2 Order status when label printed | 1 | Same mutation sets status to `processing` (or `shipped`) |
|
||||
| US-ADM-3 View tracking (live via webhook) | 2 | Shippo webhook handler; optional refresh action |
|
||||
| US-ADM-4 Return only when confirmed | 3 | `canCustomerRequestReturn` + guards in cancel/requestReturn |
|
||||
| US-ADM-5 Refund after successful return | 4 | Idempotent Stripe refund + idempotent `applyRefund` |
|
||||
|
||||
---
|
||||
|
||||
*Document version: 1.0. No UI work included; UI can consume the new/updated queries and actions as needed.*
|
||||
488
apps/admin/docs/07-orders-ui-design-plan.md
Normal file
488
apps/admin/docs/07-orders-ui-design-plan.md
Normal file
@@ -0,0 +1,488 @@
|
||||
# Orders UI — Design Plan (Admin Dashboard)
|
||||
|
||||
**Audience:** Senior software engineers
|
||||
**Scope:** Orders UI only — list page, detail page, all admin actions. No new backend work.
|
||||
**Backend reference:** `06-order-processing-backend-implementation-plan.md`
|
||||
**Design constraint:** Match the existing admin design language exactly (ShadCN only, HugeIcons, same layout shell, same patterns as Products page).
|
||||
|
||||
---
|
||||
|
||||
## 1. Design philosophy
|
||||
|
||||
Three principles guide every decision here:
|
||||
|
||||
1. **Single responsibility per component.** Each component owns one concern: a card, a table, a button, a timeline. No component manages both data display and action side-effects unless it is a leaf action component.
|
||||
2. **Status-driven UI.** The order's `status` field is the single source of truth. What you see, what buttons appear, what badges render — all computed from status. No hidden state, no guessing.
|
||||
3. **Consistency over novelty.** Use the exact same patterns already in the codebase: `rounded-lg border` for table wrappers, `text-xl font-semibold` headings, `usePaginatedQuery` + "Load more", `AlertDialog` for destructive actions, `toast` (Sonner) for feedback.
|
||||
|
||||
---
|
||||
|
||||
## 2. Route structure
|
||||
|
||||
```
|
||||
/orders → OrdersPage (list)
|
||||
/orders/[id] → OrderDetailPage (detail)
|
||||
```
|
||||
|
||||
The detail page is a full Next.js route (`app/(dashboard)/orders/[id]/page.tsx`), not a dialog. Orders are complex enough to deserve their own page; a dialog would feel cramped.
|
||||
|
||||
---
|
||||
|
||||
## 3. Status vocabulary
|
||||
|
||||
Both pages share the same two badge components. Define color intent once, reuse everywhere.
|
||||
|
||||
### 3.1 Order status badges
|
||||
|
||||
| Status | Badge variant | Tailwind override | Meaning |
|
||||
|--------|--------------|-------------------|---------|
|
||||
| `pending` | `secondary` | — | Payment not yet confirmed |
|
||||
| `confirmed` | `outline` | `border-blue-500 text-blue-600` | Paid, awaiting fulfilment |
|
||||
| `processing` | `outline` | `border-violet-500 text-violet-600` | Label created, packing |
|
||||
| `shipped` | `outline` | `border-indigo-500 text-indigo-600` | In transit |
|
||||
| `delivered` | `default` | `bg-green-600` | Delivered |
|
||||
| `cancelled` | `destructive` | — | Cancelled |
|
||||
| `refunded` | `secondary` | `line-through` | Refunded |
|
||||
|
||||
### 3.2 Payment status badges
|
||||
|
||||
| Status | Badge variant | Tailwind override |
|
||||
|--------|--------------|-------------------|
|
||||
| `pending` | `secondary` | — |
|
||||
| `paid` | `default` | `bg-green-600` |
|
||||
| `failed` | `destructive` | — |
|
||||
| `refunded` | `secondary` | — |
|
||||
|
||||
Both mappings live in `apps/admin/src/components/orders/shared/statusConfig.ts` — a plain TypeScript constants file, no JSX.
|
||||
|
||||
---
|
||||
|
||||
## 4. Orders list page
|
||||
|
||||
**File:** `apps/admin/src/app/(dashboard)/orders/page.tsx`
|
||||
|
||||
### 4.1 Layout (top to bottom)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Orders │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ [🔍 Search by order # or email…] [Status ▾] │
|
||||
├─────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────────────────┐ │
|
||||
│ │ Order # │ Customer │ Date │ Status │ Total │ │
|
||||
│ │──────────┼──────────┼──────┼────────┼───────│ │
|
||||
│ │ ORD-AB12 │ Jane Doe │ … │ [Conf] │ £42 │ │
|
||||
│ └─────────────────────────────────────────────┘ │
|
||||
│ 12 loaded [Load more] │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 Data fetching
|
||||
|
||||
- **Default (no filter, no search):** `usePaginatedQuery(api.orders.listAll, {}, { initialNumItems: 25 })`
|
||||
- **Status filter active:** `usePaginatedQuery(api.orders.listAll, { status }, { initialNumItems: 25 })`
|
||||
- Pagination: "Load more" button + count footer — identical to ProductsPage.
|
||||
- No search query for now (orders list does not have a full-text search index). Search by order number or email is client-side filtered from the loaded page; add a note in the component that server-side search can be added when an index exists.
|
||||
|
||||
### 4.3 Table columns
|
||||
|
||||
| Column | Notes |
|
||||
|--------|-------|
|
||||
| Order # | `font-mono text-xs`; clicking the row navigates to detail |
|
||||
| Customer | `order.email`; `text-muted-foreground` |
|
||||
| Date | `createdAt` formatted short; `text-xs text-muted-foreground` |
|
||||
| Status | `<OrderStatusBadge status={order.status} />` |
|
||||
| Payment | `<OrderPaymentBadge status={order.paymentStatus} />` |
|
||||
| Total | `formatPrice(order.total)` right-aligned |
|
||||
|
||||
No actions menu column — clicking anywhere on the row navigates to the detail page. Keeps the list clean. A chevron icon on the right of each row communicates navigability.
|
||||
|
||||
### 4.4 Extracted components
|
||||
|
||||
| Component | File | Responsibility |
|
||||
|-----------|------|---------------|
|
||||
| `OrderStatusBadge` | `components/orders/shared/OrderStatusBadge.tsx` | Renders one `<Badge>` for order status |
|
||||
| `OrderPaymentBadge` | `components/orders/shared/OrderPaymentBadge.tsx` | Renders one `<Badge>` for payment status |
|
||||
| `OrdersTableSkeleton` | inline in list page | 10 skeleton rows, same columns |
|
||||
|
||||
### 4.5 Empty and loading states
|
||||
|
||||
- **Loading:** 10 skeleton rows with matching column widths.
|
||||
- **Empty (no orders):** Centered row: *"No orders yet."*
|
||||
- **Empty (filter applied):** *"No orders with status 'confirmed'."*
|
||||
|
||||
---
|
||||
|
||||
## 5. Order detail page
|
||||
|
||||
**File:** `apps/admin/src/app/(dashboard)/orders/[id]/page.tsx`
|
||||
|
||||
This page uses `useQuery(api.orders.getById, { id })` and `useQuery(api.orders.getTimeline, { orderId: id })` to load both the order and its timeline in parallel.
|
||||
|
||||
### 5.1 Layout
|
||||
|
||||
Two-column layout on `md+`, stacked on mobile. The left column is wider (about 60%) and holds the primary data. The right column holds contextual cards.
|
||||
|
||||
```
|
||||
┌──────────────────────┬───────────────────┐
|
||||
│ │ │
|
||||
│ ← Orders │ Customer │
|
||||
│ ORD-AB12 [Conf] │ Jane Doe │
|
||||
│ 14 Jan 2026 │ jane@example.com │
|
||||
│ ├───────────────────┤
|
||||
│ [Admin actions bar] │ Shipping address │
|
||||
│ │ 123 Main St… │
|
||||
├──────────────────────┤ │
|
||||
│ Order items ├───────────────────┤
|
||||
│ ────────────────── │ Fulfilment │
|
||||
│ Product Qty Total │ Carrier: DPD UK │
|
||||
│ … │ Service: Next Day│
|
||||
├──────────────────────┤ [Create Label] │
|
||||
│ Financials ├───────────────────┤
|
||||
│ Subtotal £38.00 │ │
|
||||
│ Shipping £4.99 │ (empty if no │
|
||||
│ Total £42.99 │ tracking yet) │
|
||||
├──────────────────────┤ │
|
||||
│ Timeline │ │
|
||||
│ ○ Order confirmed │ │
|
||||
│ ○ Label created │ │
|
||||
│ ○ Delivered │ │
|
||||
└──────────────────────┴───────────────────┘
|
||||
```
|
||||
|
||||
On mobile, right column cards stack below the left column, in this order: Customer → Shipping → Fulfilment.
|
||||
|
||||
### 5.2 Loading state
|
||||
|
||||
All cards render as `Skeleton` blocks while data is `undefined`. Each card has its own skeleton shape so the layout does not shift when data loads. No full-page spinner.
|
||||
|
||||
### 5.3 Component breakdown
|
||||
|
||||
Every component below has **one job**. None of them fetch data themselves — the page fetches and passes props down.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.1 `OrderPageHeader`
|
||||
|
||||
**File:** `components/orders/detail/OrderPageHeader.tsx`
|
||||
**Job:** Back link, order number, date, status badges.
|
||||
|
||||
```
|
||||
← Orders ORD-AB12 [Confirmed] [Paid] 14 Jan 2026, 10:22
|
||||
```
|
||||
|
||||
Props: `orderNumber`, `createdAt`, `status`, `paymentStatus`.
|
||||
No actions here. Badges are read-only. Headings use `text-xl font-semibold`.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.2 `OrderActionsBar`
|
||||
|
||||
**File:** `components/orders/detail/OrderActionsBar.tsx`
|
||||
**Job:** Render the correct set of action buttons based on the order's current status. This is the only component that "knows" which actions are valid — it reads `status`, `paymentStatus`, `trackingNumber`, `returnRequestedAt`, `returnReceivedAt` and decides what to render.
|
||||
|
||||
Action matrix:
|
||||
|
||||
| Condition | Buttons shown |
|
||||
|-----------|--------------|
|
||||
| `status === "confirmed"` and no `trackingNumber` | **Create Label** (primary), **Update Status** (outline) |
|
||||
| `status === "confirmed"` and `trackingNumber` exists | **Update Status** (outline) |
|
||||
| `status === "confirmed"` and `returnRequestedAt` and no `returnReceivedAt` | **Mark Return Received** (outline), **Update Status** |
|
||||
| `status === "confirmed"` and `returnReceivedAt` and `paymentStatus !== "refunded"` | **Issue Refund** (destructive), **Update Status** |
|
||||
| `status === "processing"` or `"shipped"` | **Update Status** only |
|
||||
| `status === "delivered"` and `paymentStatus !== "refunded"` | **Issue Refund** (destructive), **Update Status** |
|
||||
| `status === "cancelled"` or `"refunded"` | Read-only — no buttons |
|
||||
|
||||
**`OrderActionsBar` does not implement the action logic itself.** It renders the individual action components below. It is purely a conditional layout shell.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.3 `CreateLabelButton`
|
||||
|
||||
**File:** `components/orders/actions/CreateLabelButton.tsx`
|
||||
**Job:** Call `api.fulfillmentActions.createShippingLabel`, show loading spinner, surface structured errors.
|
||||
|
||||
- Uses `useAction(api.fulfillmentActions.createShippingLabel)`.
|
||||
- On click: sets loading, calls action, on success shows `toast.success("Label created. Tracking: {trackingNumber}")`, on structured error shows `toast.error(result.message)`, on thrown error shows generic toast.
|
||||
- Button label: "Create Label" → "Creating…" while loading.
|
||||
- Disabled while loading.
|
||||
- No AlertDialog — creating a label is not destructive.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.4 `UpdateStatusDialog`
|
||||
|
||||
**File:** `components/orders/actions/UpdateStatusDialog.tsx`
|
||||
**Job:** A `Dialog` containing a `Select` of valid next statuses. On confirm, calls `api.orders.updateStatus`.
|
||||
|
||||
- Triggered by "Update Status" button in `OrderActionsBar`.
|
||||
- Select shows all statuses except the current one.
|
||||
- Submit button shows spinner while mutating.
|
||||
- On success: `toast.success("Status updated to {status}")`.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.5 `MarkReturnReceivedButton`
|
||||
|
||||
**File:** `components/orders/actions/MarkReturnReceivedButton.tsx`
|
||||
**Job:** Call `api.orders.markReturnReceived` with an `AlertDialog` confirmation.
|
||||
|
||||
- AlertDialog title: *"Mark return as received?"*
|
||||
- Description: *"Confirm that the returned items have arrived. You can then issue a refund."*
|
||||
- Uses `useMutation(api.orders.markReturnReceived)`.
|
||||
- Shows spinner on the confirm button while loading.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.6 `IssueRefundButton`
|
||||
|
||||
**File:** `components/orders/actions/IssueRefundButton.tsx`
|
||||
**Job:** Call `api.returnActions.issueRefund` with an `AlertDialog` confirmation.
|
||||
|
||||
- AlertDialog title: *"Issue full refund?"*
|
||||
- Description: *"A full refund of £{order.total} will be sent to the customer via Stripe. This cannot be undone."*
|
||||
- Uses `useAction(api.returnActions.issueRefund)`.
|
||||
- Confirm button: destructive variant, "Issue Refund" → "Refunding…".
|
||||
- On success: `toast.success("Refund issued")`.
|
||||
- On error: `toast.error(err.message)`.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.7 `OrderItemsCard`
|
||||
|
||||
**File:** `components/orders/detail/OrderItemsCard.tsx`
|
||||
**Job:** Display the line items in a `Card` with a bordered table inside.
|
||||
|
||||
Columns: Product name (+ variant name below in `text-xs text-muted-foreground`), SKU (`font-mono text-xs`), Qty, Unit price, Total.
|
||||
No actions. Read-only.
|
||||
|
||||
```
|
||||
┌─ Order items ────────────────────────────────────┐
|
||||
│ Product SKU Qty Unit Total │
|
||||
│ Royal Canin… │
|
||||
│ Adult 2kg SKU-001 2 £19.00 £38.00 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.8 `OrderFinancialsCard`
|
||||
|
||||
**File:** `components/orders/detail/OrderFinancialsCard.tsx`
|
||||
**Job:** Render the price breakdown.
|
||||
|
||||
Uses `InfoRow`-style layout (label left, value right) inside a `Card`. Subtotal, shipping, discount (if > 0), tax (if > 0), and a `Separator` before the bold total.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.9 `CustomerCard`
|
||||
|
||||
**File:** `components/orders/detail/CustomerCard.tsx`
|
||||
**Job:** Display the customer name and email.
|
||||
|
||||
`Card` with `CardHeader` (title "Customer") and `CardContent`. Email is a `mailto:` link styled as `text-sm text-muted-foreground hover:underline`.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.10 `ShippingAddressCard`
|
||||
|
||||
**File:** `components/orders/detail/ShippingAddressCard.tsx`
|
||||
**Job:** Render the `shippingAddressSnapshot` in a `Card`.
|
||||
|
||||
Displays each address line in `text-sm`, country in `text-xs text-muted-foreground`. No editing — it's a snapshot.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.11 `FulfilmentCard`
|
||||
|
||||
**File:** `components/orders/detail/FulfilmentCard.tsx`
|
||||
**Job:** Shipping service info + tracking state. No action buttons — those live in `OrderActionsBar`.
|
||||
|
||||
Three visual states:
|
||||
|
||||
**State A — No label yet (status: `confirmed`, no `trackingNumber`):**
|
||||
```
|
||||
┌─ Fulfilment ──────────────────────────────────┐
|
||||
│ Carrier DPD UK │
|
||||
│ Service Next Day │
|
||||
│ Method Express │
|
||||
│ │
|
||||
│ No label created yet. │
|
||||
└───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**State B — Label created, in transit:**
|
||||
```
|
||||
┌─ Fulfilment ──────────────────────────────────┐
|
||||
│ Carrier DPD UK │
|
||||
│ Service Next Day │
|
||||
│ Tracking 1Z999AA1… [↗ Track] │
|
||||
│ Status TRANSIT │
|
||||
│ Est. delivery 18 Jan 2026 │
|
||||
│ Label [↗ Download] │
|
||||
└───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**State C — Delivered:**
|
||||
```
|
||||
┌─ Fulfilment ──────────────────────────────────┐
|
||||
│ Carrier DPD UK │
|
||||
│ Tracking 1Z999AA1… [↗ Track] │
|
||||
│ Status DELIVERED │
|
||||
│ Delivered 17 Jan 2026, 14:35 │
|
||||
└───────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The `trackingStatus` field drives the displayed status string. Tracking and label URLs open in a new tab.
|
||||
|
||||
---
|
||||
|
||||
#### 5.3.12 `OrderTimelineCard`
|
||||
|
||||
**File:** `components/orders/detail/OrderTimelineCard.tsx`
|
||||
**Job:** Render `orderTimelineEvents` as a vertical stepper/timeline.
|
||||
|
||||
Receives the `events` array as a prop (fetched by the page).
|
||||
|
||||
Each event row:
|
||||
```
|
||||
○ Status changed: confirmed → processing 14 Jan · 10:35
|
||||
by admin
|
||||
```
|
||||
|
||||
Event type → human label mapping:
|
||||
|
||||
| `eventType` | Display label |
|
||||
|-------------|--------------|
|
||||
| `status_change` | "Status changed: {fromStatus} → {toStatus}" |
|
||||
| `label_created` | "Shipping label created" + tracking number from payload |
|
||||
| `tracking_update` | "Tracking update: {status}" |
|
||||
| `customer_cancel` | "Customer requested cancellation" |
|
||||
| `return_requested` | "Customer requested return" |
|
||||
| `return_received` | "Return marked as received" |
|
||||
| `refund` | "Refund issued" |
|
||||
|
||||
The dot (`○`) color matches the status badge color for `status_change` events; neutral for all others.
|
||||
|
||||
Loading state: 3–4 skeleton rows.
|
||||
Empty state: *"No activity yet."*
|
||||
|
||||
---
|
||||
|
||||
## 6. Shared utilities
|
||||
|
||||
**File:** `components/orders/shared/statusConfig.ts`
|
||||
|
||||
```typescript
|
||||
// Order status → { label, badgeVariant, className }
|
||||
// Payment status → { label, badgeVariant, className }
|
||||
// Event type → human label
|
||||
// Timeline dot color by toStatus
|
||||
```
|
||||
|
||||
Keep all display constants here. Both pages and all badge components import from this single file. No duplication.
|
||||
|
||||
---
|
||||
|
||||
## 7. File tree
|
||||
|
||||
```
|
||||
apps/admin/src/
|
||||
├── app/(dashboard)/
|
||||
│ └── orders/
|
||||
│ ├── page.tsx ← Orders list page
|
||||
│ └── [id]/
|
||||
│ └── page.tsx ← Order detail page
|
||||
└── components/
|
||||
└── orders/
|
||||
├── shared/
|
||||
│ ├── statusConfig.ts ← Constants only, no JSX
|
||||
│ ├── OrderStatusBadge.tsx
|
||||
│ └── OrderPaymentBadge.tsx
|
||||
├── detail/
|
||||
│ ├── OrderPageHeader.tsx
|
||||
│ ├── OrderActionsBar.tsx ← Layout shell, no action logic
|
||||
│ ├── OrderItemsCard.tsx
|
||||
│ ├── OrderFinancialsCard.tsx
|
||||
│ ├── CustomerCard.tsx
|
||||
│ ├── ShippingAddressCard.tsx
|
||||
│ ├── FulfilmentCard.tsx
|
||||
│ └── OrderTimelineCard.tsx
|
||||
└── actions/
|
||||
├── CreateLabelButton.tsx
|
||||
├── UpdateStatusDialog.tsx
|
||||
├── MarkReturnReceivedButton.tsx
|
||||
└── IssueRefundButton.tsx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Data flow
|
||||
|
||||
```
|
||||
OrderDetailPage (page.tsx)
|
||||
│
|
||||
│ useQuery(api.orders.getById, { id }) → order
|
||||
│ useQuery(api.orders.getTimeline, { orderId }) → events
|
||||
│
|
||||
├── OrderPageHeader (order)
|
||||
├── OrderActionsBar (order)
|
||||
│ ├── CreateLabelButton (orderId, order.status, order.trackingNumber)
|
||||
│ ├── UpdateStatusDialog (orderId, order.status)
|
||||
│ ├── MarkReturnReceivedButton (orderId)
|
||||
│ └── IssueRefundButton (orderId, order.total, order.currency)
|
||||
│
|
||||
├── [left column]
|
||||
│ ├── OrderItemsCard (order.items)
|
||||
│ ├── OrderFinancialsCard (order)
|
||||
│ └── OrderTimelineCard (events)
|
||||
│
|
||||
└── [right column]
|
||||
├── CustomerCard (order.email, order.userId)
|
||||
├── ShippingAddressCard (order.shippingAddressSnapshot)
|
||||
└── FulfilmentCard (order)
|
||||
```
|
||||
|
||||
All queries are read-only in the page. Action components call mutations/actions themselves and handle their own loading/error state. The page never passes mutation functions as props.
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation order
|
||||
|
||||
| Step | What to build | Why first |
|
||||
|------|---------------|-----------|
|
||||
| 1 | `statusConfig.ts`, `OrderStatusBadge`, `OrderPaymentBadge` | Shared atoms used by both pages |
|
||||
| 2 | Orders list page | Validates data fetching works, gives navigation to detail |
|
||||
| 3 | `OrderPageHeader`, `OrderItemsCard`, `OrderFinancialsCard` | Core detail page scaffold — read-only |
|
||||
| 4 | `CustomerCard`, `ShippingAddressCard`, `FulfilmentCard` | Right column — all read-only |
|
||||
| 5 | `OrderTimelineCard` | Needs timeline query wired |
|
||||
| 6 | `UpdateStatusDialog` | Lowest-risk action — any status |
|
||||
| 7 | `CreateLabelButton` | Most impactful Phase 1 action |
|
||||
| 8 | `MarkReturnReceivedButton`, `IssueRefundButton` | Phase 3/4 actions |
|
||||
| 9 | `OrderActionsBar` | Wire all actions together with condition logic |
|
||||
|
||||
Build read-only views first, add write actions last. This way the detail page is useful even before all actions are wired.
|
||||
|
||||
---
|
||||
|
||||
## 10. Design decisions and rationale
|
||||
|
||||
**Why a full page for detail, not a dialog?**
|
||||
Orders have 5+ cards, a timeline, and multiple action flows. A dialog forces vertical scrolling with no space for the two-column layout. A page also makes deep-linking (e.g. linking from a customer email) trivial.
|
||||
|
||||
**Why does `OrderActionsBar` not implement actions?**
|
||||
If it did, adding or changing one action would require modifying a large, complex component. With leaf action components, each action is independently testable and replaceable.
|
||||
|
||||
**Why no search on the list page?**
|
||||
The `orders` table has no full-text search index. Client-side filtering over 25 loaded rows is fine for a small operation, but it does not scale past the first page. A future iteration can add a `by_order_number` or `by_email` query when the need is clear.
|
||||
|
||||
**Why `useAction` for `createShippingLabel` and `issueRefund`?**
|
||||
Both are Convex actions (they call external APIs). `useAction` from `convex/react` is the correct hook — not `useMutation`.
|
||||
|
||||
**Why show `trackingStatus` in `FulfilmentCard` rather than a separate card?**
|
||||
Tracking info and fulfilment info are tightly related; an admin needs them together. Separating them would force eye-scanning across two cards to understand the delivery state.
|
||||
|
||||
---
|
||||
|
||||
*Document version: 1.0. UI only — no new backend work required.*
|
||||
189
apps/admin/docs/other/Image_Processing_API.md
Normal file
189
apps/admin/docs/other/Image_Processing_API.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# API Usage Guide
|
||||
|
||||
This document describes how to use the background removal API.
|
||||
|
||||
---
|
||||
|
||||
## Base URL
|
||||
|
||||
- Local: `http://localhost:8000`
|
||||
- Production: `http://localhost:8000`
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. API Info
|
||||
|
||||
```
|
||||
GET /
|
||||
```
|
||||
|
||||
Returns service metadata and links.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"service": "withoutbg-api",
|
||||
"version": "1.0.2",
|
||||
"docs": "/docs",
|
||||
"health": "/api/health"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Health Check
|
||||
|
||||
```
|
||||
GET /api/health
|
||||
```
|
||||
|
||||
Verify the API is running and models are loaded.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"version": "1.0.2",
|
||||
"service": "withoutbg-api",
|
||||
"models_loaded": true
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Remove Background
|
||||
|
||||
```
|
||||
POST /api/remove-background
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
Remove the background from an image and return the result.
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type | Required | Default | Description |
|
||||
|---------|--------|----------|---------|------------------------------------------------------------|
|
||||
| `file` | File | Yes | — | Image file (PNG, JPEG, WebP) |
|
||||
| `format`| String | No | `webp` | Output format: `webp`, `png`, or `jpg` |
|
||||
| `quality` | Integer | No | `95` | Compression quality for WebP/JPEG (1–100) |
|
||||
|
||||
#### Request Example (curl)
|
||||
|
||||
```bash
|
||||
# WebP output (default)
|
||||
curl -X POST https://your-api.com/api/remove-background \
|
||||
-F "file=@/path/to/photo.jpg" \
|
||||
-o result.webp
|
||||
|
||||
# PNG output
|
||||
curl -X POST https://your-api.com/api/remove-background \
|
||||
-F "file=@photo.png" \
|
||||
-F "format=png" \
|
||||
-o result.png
|
||||
|
||||
# JPEG output with quality
|
||||
curl -X POST https://your-api.com/api/remove-background \
|
||||
-F "file=@photo.jpg" \
|
||||
-F "format=jpg" \
|
||||
-F "quality=90" \
|
||||
-o result.jpg
|
||||
```
|
||||
|
||||
#### Request Example (Python)
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
url = "https://your-api.com/api/remove-background"
|
||||
|
||||
with open("photo.jpg", "rb") as f:
|
||||
response = requests.post(
|
||||
url,
|
||||
files={"file": ("photo.jpg", f, "image/jpeg")},
|
||||
data={"format": "webp", "quality": 95},
|
||||
)
|
||||
|
||||
if response.ok:
|
||||
with open("result.webp", "wb") as out:
|
||||
out.write(response.content)
|
||||
else:
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
#### Request Example (JavaScript / Fetch)
|
||||
|
||||
```javascript
|
||||
const formData = new FormData();
|
||||
formData.append("file", imageFile);
|
||||
formData.append("format", "webp");
|
||||
formData.append("quality", "95");
|
||||
|
||||
const response = await fetch("https://your-api.com/api/remove-background", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
// Use blob (e.g. create download link, display in img)
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error(error.detail);
|
||||
}
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
- **Success (200):** Binary image (WebP, PNG, or JPEG)
|
||||
- **Content-Type:** `image/webp`, `image/png`, or `image/jpeg`
|
||||
- **Content-Disposition:** `inline; filename=withoutbg.{format}`
|
||||
|
||||
#### Response Headers
|
||||
|
||||
| Header | Description |
|
||||
|-----------------------|-----------------------------------------------------------------------------|
|
||||
| `X-Format-Fallback` | Present if WebP was requested but PNG was returned (e.g. `X-Format-Fallback: png`) |
|
||||
|
||||
---
|
||||
|
||||
## Processing Behavior
|
||||
|
||||
- Input image is centered on a transparent square canvas (height × height)
|
||||
- Output is resized so the longest side is at most 1000px
|
||||
- Output size is limited to ~50KB; if larger, the API may compress further
|
||||
- Supported input formats: PNG, JPEG, WebP
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
Errors return JSON with a `detail` field.
|
||||
|
||||
| Status | Meaning | Example `detail` |
|
||||
|--------|----------------------------|-----------------------------------------|
|
||||
| 400 | Bad request | `File too small (64 bytes)...` |
|
||||
| 400 | Invalid image | `Invalid or unsupported image format` |
|
||||
| 500 | Processing error | `Processing failed: ...` |
|
||||
| 503 | Service not ready | `Models not loaded. Server may still be starting up.` |
|
||||
|
||||
**Example:**
|
||||
```json
|
||||
{
|
||||
"detail": "File too small (64 bytes). Verify the file exists and use full path: -F 'file=@/path/to/image.webp'"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interactive Documentation
|
||||
|
||||
When the API is running, Swagger UI is available at:
|
||||
|
||||
```
|
||||
GET /docs
|
||||
```
|
||||
|
||||
Use it to try endpoints from the browser.
|
||||
6
apps/admin/next-env.d.ts
vendored
Normal file
6
apps/admin/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
19
apps/admin/next.config.js
Normal file
19
apps/admin/next.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const path = require("path");
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@repo/convex", "@repo/types", "@repo/utils"],
|
||||
turbopack: {
|
||||
root: path.join(__dirname, "..", ".."),
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "res.cloudinary.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
42
apps/admin/package.json
Normal file
42
apps/admin/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@clerk/nextjs": "^6.38.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@hugeicons/core-free-icons": "^3.3.0",
|
||||
"@hugeicons/react": "^1.1.5",
|
||||
"@repo/convex": "*",
|
||||
"@repo/types": "*",
|
||||
"@repo/utils": "*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cloudinary": "^2.9.0",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.6.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"postcss": "^8.4.0",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
}
|
||||
}
|
||||
5
apps/admin/postcss.config.js
Normal file
5
apps/admin/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
1
apps/admin/public/illustrations/404_not_found.svg
Normal file
1
apps/admin/public/illustrations/404_not_found.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 60 KiB |
1
apps/admin/public/illustrations/still_building.svg
Normal file
1
apps/admin/public/illustrations/still_building.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 75 KiB |
@@ -0,0 +1,9 @@
|
||||
import { SignIn } from "@clerk/nextjs";
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center">
|
||||
<SignIn />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
116
apps/admin/src/app/(dashboard)/images/page.tsx
Normal file
116
apps/admin/src/app/(dashboard)/images/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { api } from "../../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../../convex/_generated/dataModel"
|
||||
import { ProductSearchSection } from "../../../components/shared/ProductSearchSection"
|
||||
import { ProductImageCarousel } from "../../../components/images/ProductImageCarousel"
|
||||
import { ImageUploadSection } from "../../../components/images/ImageUploadSection"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
interface SelectedProduct {
|
||||
_id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function ImagesPage() {
|
||||
const [selectedProduct, setSelectedProduct] = useState<SelectedProduct | null>(null)
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
|
||||
const productData = useQuery(
|
||||
api.products.getByIdForAdmin,
|
||||
selectedProduct ? { id: selectedProduct._id as Id<"products"> } : "skip",
|
||||
)
|
||||
|
||||
function handleProductSelect(product: SelectedProduct) {
|
||||
setSelectedProduct(product)
|
||||
setShowUpload(false)
|
||||
}
|
||||
|
||||
function handleSearchClear() {
|
||||
setSelectedProduct(null)
|
||||
setShowUpload(false)
|
||||
}
|
||||
|
||||
function handleUploadSuccess() {
|
||||
setShowUpload(false)
|
||||
}
|
||||
|
||||
const images = productData?.images ?? []
|
||||
const nextPosition =
|
||||
images.length > 0 ? Math.max(...images.map((img) => img.position)) + 1 : 0
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
|
||||
<h1 className="text-xl font-semibold">Product images</h1>
|
||||
|
||||
{/* Product search */}
|
||||
<section className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Select a product</p>
|
||||
<ProductSearchSection
|
||||
onSelect={handleProductSelect}
|
||||
onClear={handleSearchClear}
|
||||
selectedId={selectedProduct?._id}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Gallery */}
|
||||
{selectedProduct && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{selectedProduct.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{productData === undefined
|
||||
? "Loading images…"
|
||||
: `${images.length} image${images.length !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{productData === undefined ? (
|
||||
<div className="flex gap-4">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="aspect-square w-32 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<ProductImageCarousel
|
||||
images={images as any}
|
||||
onAddMore={() => setShowUpload(true)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Upload section */}
|
||||
{showUpload && productData && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="max-w-lg space-y-2">
|
||||
<h3 className="text-sm font-semibold">Add image</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select an image. The background will be removed automatically before upload.
|
||||
</p>
|
||||
<ImageUploadSection
|
||||
productId={selectedProduct._id as Id<"products">}
|
||||
nextPosition={nextPosition}
|
||||
onSuccess={handleUploadSuccess}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!selectedProduct && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Search for a product above to manage its images.
|
||||
</p>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
42
apps/admin/src/app/(dashboard)/layout.tsx
Normal file
42
apps/admin/src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { AdminUserSync } from "../../components/auth/AdminUserSync";
|
||||
import { AdminAuthGate } from "../../components/auth/AdminAuthGate";
|
||||
import { AppSidebar } from "../../components/layout/sidebar/app-sidebar";
|
||||
import { DynamicBreadcrumb } from "../../components/layout/DynamicBreadcrumb";
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<AdminUserSync />
|
||||
<AdminAuthGate>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-4 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<DynamicBreadcrumb />
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col gap-4 p-4">
|
||||
{children}
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</AdminAuthGate>
|
||||
</>
|
||||
);
|
||||
}
|
||||
143
apps/admin/src/app/(dashboard)/orders/[id]/page.tsx
Normal file
143
apps/admin/src/app/(dashboard)/orders/[id]/page.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import { useQuery } from "convex/react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { api } from "../../../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../../../convex/_generated/dataModel"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card"
|
||||
import { OrderPageHeader } from "@/components/orders/detail/OrderPageHeader"
|
||||
import { OrderItemsCard } from "@/components/orders/detail/OrderItemsCard"
|
||||
import { OrderFinancialsCard } from "@/components/orders/detail/OrderFinancialsCard"
|
||||
import { CustomerCard } from "@/components/orders/detail/CustomerCard"
|
||||
import { ShippingAddressCard } from "@/components/orders/detail/ShippingAddressCard"
|
||||
import { FulfilmentCard } from "@/components/orders/detail/FulfilmentCard"
|
||||
import { OrderTimelineCard } from "@/components/orders/detail/OrderTimelineCard"
|
||||
import { OrderActionsBar } from "@/components/orders/detail/OrderActionsBar"
|
||||
|
||||
// ─── Per-card skeletons (no layout shift on load) ─────────────────────────────
|
||||
|
||||
function HeaderSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-7 w-32" />
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-4 w-36" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardSkeleton({ rows = 3 }: { rows?: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-5 w-28" />
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-4 w-full" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const params = useParams()
|
||||
const orderId = params.id as Id<"orders">
|
||||
|
||||
const order = useQuery(api.orders.getById, { id: orderId })
|
||||
// Timeline query is wired here for Phase 5 — passed to OrderTimelineCard
|
||||
const events = useQuery(api.orders.getTimeline, { orderId })
|
||||
|
||||
// ── Loading state ───────────────────────────────────────────────────────────
|
||||
if (order === undefined) {
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
|
||||
<HeaderSkeleton />
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_340px]">
|
||||
{/* Left column */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<CardSkeleton rows={4} />
|
||||
<CardSkeleton rows={4} />
|
||||
<CardSkeleton rows={5} />
|
||||
</div>
|
||||
{/* Right column */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<CardSkeleton rows={2} />
|
||||
<CardSkeleton rows={3} />
|
||||
<CardSkeleton rows={4} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
|
||||
{/* Header — back link, order#, date, status badges */}
|
||||
<OrderPageHeader
|
||||
orderNumber={order.orderNumber}
|
||||
createdAt={order.createdAt}
|
||||
status={order.status}
|
||||
paymentStatus={order.paymentStatus}
|
||||
/>
|
||||
|
||||
<OrderActionsBar
|
||||
orderId={order._id}
|
||||
status={order.status}
|
||||
paymentStatus={order.paymentStatus}
|
||||
trackingNumber={order.trackingNumber}
|
||||
returnRequestedAt={order.returnRequestedAt}
|
||||
returnReceivedAt={order.returnReceivedAt}
|
||||
total={order.total}
|
||||
currency={order.currency}
|
||||
/>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_340px]">
|
||||
{/* ── Left column ──────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<OrderItemsCard items={order.items} currency={order.currency} />
|
||||
<OrderFinancialsCard
|
||||
subtotal={order.subtotal}
|
||||
shipping={order.shipping}
|
||||
discount={order.discount}
|
||||
tax={order.tax}
|
||||
total={order.total}
|
||||
currency={order.currency}
|
||||
/>
|
||||
<OrderTimelineCard events={events} />
|
||||
</div>
|
||||
|
||||
{/* ── Right column ─────────────────────────────────────────────────── */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<CustomerCard
|
||||
name={order.shippingAddressSnapshot.fullName}
|
||||
email={order.email}
|
||||
/>
|
||||
<ShippingAddressCard address={order.shippingAddressSnapshot} />
|
||||
<FulfilmentCard
|
||||
carrier={order.carrier}
|
||||
shippingMethod={order.shippingMethod}
|
||||
shippingServiceCode={order.shippingServiceCode}
|
||||
trackingNumber={order.trackingNumber}
|
||||
trackingUrl={order.trackingUrl}
|
||||
labelUrl={order.labelUrl}
|
||||
returnLabelUrl={order.returnLabelUrl}
|
||||
trackingStatus={order.trackingStatus}
|
||||
estimatedDelivery={order.estimatedDelivery}
|
||||
actualDelivery={order.actualDelivery}
|
||||
status={order.status}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
267
apps/admin/src/app/(dashboard)/orders/page.tsx
Normal file
267
apps/admin/src/app/(dashboard)/orders/page.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo } from "react"
|
||||
import { usePaginatedQuery } from "convex/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { api } from "../../../../../../convex/_generated/api"
|
||||
import { formatPrice } from "@repo/utils"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import {
|
||||
Search01Icon,
|
||||
Cancel01Icon,
|
||||
ArrowRight01Icon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
import { OrderStatusBadge } from "@/components/orders/shared/OrderStatusBadge"
|
||||
import { OrderPaymentBadge } from "@/components/orders/shared/OrderPaymentBadge"
|
||||
import {
|
||||
ORDER_STATUS_CONFIG,
|
||||
type OrderStatus,
|
||||
} from "@/components/orders/shared/statusConfig"
|
||||
|
||||
// ─── Status filter options ────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "all", label: "All statuses" },
|
||||
...Object.entries(ORDER_STATUS_CONFIG).map(([value, config]) => ({
|
||||
value,
|
||||
label: config.label,
|
||||
})),
|
||||
]
|
||||
|
||||
// ─── Skeleton ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-20 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="size-4" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function OrdersPage() {
|
||||
const router = useRouter()
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
|
||||
const {
|
||||
results,
|
||||
status,
|
||||
loadMore,
|
||||
} = usePaginatedQuery(
|
||||
api.orders.listAll,
|
||||
statusFilter !== "all" ? { status: statusFilter } : {},
|
||||
{ initialNumItems: 25 },
|
||||
)
|
||||
|
||||
const isLoading = status === "LoadingFirstPage"
|
||||
|
||||
// Client-side search over loaded results.
|
||||
// NOTE: filtered to the currently loaded page only — no server-side search
|
||||
// index exists yet. Add api.orders.searchByOrderNumberOrEmail when needed.
|
||||
const orders = useMemo(() => {
|
||||
const q = searchInput.trim().toLowerCase()
|
||||
if (!q) return results
|
||||
return results.filter(
|
||||
(o) =>
|
||||
o.orderNumber.toLowerCase().includes(q) ||
|
||||
o.email.toLowerCase().includes(q),
|
||||
)
|
||||
}, [results, searchInput])
|
||||
|
||||
const isSearching = searchInput.trim().length > 0
|
||||
|
||||
function emptyMessage() {
|
||||
if (isSearching) return `No orders match "${searchInput.trim()}".`
|
||||
if (statusFilter !== "all") {
|
||||
const label =
|
||||
ORDER_STATUS_CONFIG[statusFilter as OrderStatus]?.label ?? statusFilter
|
||||
return `No orders with status "${label}".`
|
||||
}
|
||||
return "No orders yet."
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Orders</h1>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<HugeiconsIcon
|
||||
icon={Search01Icon}
|
||||
strokeWidth={2}
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search by order # or email…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-8 pr-8"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSearchInput("")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Cancel01Icon}
|
||||
strokeWidth={2}
|
||||
className="size-4"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger size="default" className="w-40">
|
||||
<SelectValue placeholder="All statuses" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">Order #</TableHead>
|
||||
<TableHead scope="col">Customer</TableHead>
|
||||
<TableHead scope="col">Date</TableHead>
|
||||
<TableHead scope="col">Status</TableHead>
|
||||
<TableHead scope="col">Payment</TableHead>
|
||||
<TableHead scope="col" className="text-right">
|
||||
Total
|
||||
</TableHead>
|
||||
<TableHead scope="col" className="w-8" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : orders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="py-16 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{emptyMessage()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
orders.map((order) => (
|
||||
<TableRow
|
||||
key={order._id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/orders/${order._id}`)}
|
||||
>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{order.orderNumber}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{order.email}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{new Date(order.createdAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<OrderStatusBadge status={order.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<OrderPaymentBadge status={order.paymentStatus} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatPrice(order.total, order.currency.toUpperCase())}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowRight01Icon}
|
||||
strokeWidth={2}
|
||||
className="size-4 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination footer — list mode only (not shown during client-side search) */}
|
||||
{!isSearching && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{status === "Exhausted"
|
||||
? `${results.length} order${results.length !== 1 ? "s" : ""} total`
|
||||
: `${results.length} loaded`}
|
||||
</span>
|
||||
{status === "CanLoadMore" && (
|
||||
<Button variant="outline" size="sm" onClick={() => loadMore(25)}>
|
||||
Load more
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
14
apps/admin/src/app/(dashboard)/page.tsx
Normal file
14
apps/admin/src/app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<main>
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||
<div className="aspect-video rounded-xl bg-muted/50" />
|
||||
</div>
|
||||
<div className="min-h-[100vh] flex-1 rounded-xl bg-muted/50 md:min-h-min lg:min-h-[100vh]" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
204
apps/admin/src/app/(dashboard)/products/[id]/edit/page.tsx
Normal file
204
apps/admin/src/app/(dashboard)/products/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQuery, useMutation } from "convex/react"
|
||||
import { useRouter, useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { api } from "../../../../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../../../../convex/_generated/dataModel"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {
|
||||
ProductForm,
|
||||
buildProductPayload,
|
||||
type ProductFormValues,
|
||||
} from "@/components/products/ProductForm"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { ArrowLeft01Icon, Archive01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
export default function EditProductPage() {
|
||||
const params = useParams()
|
||||
const productId = params.id as Id<"products">
|
||||
const router = useRouter()
|
||||
|
||||
const product = useQuery(api.products.getByIdForAdmin, { id: productId })
|
||||
const categories = useQuery(api.categories.list, {}) ?? []
|
||||
const updateProduct = useMutation(api.products.update)
|
||||
const archiveProduct = useMutation(api.products.archive)
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [archiveOpen, setArchiveOpen] = useState(false)
|
||||
const [isArchiving, setIsArchiving] = useState(false)
|
||||
|
||||
async function handleSubmit(values: ProductFormValues) {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload = buildProductPayload(values)
|
||||
await updateProduct({
|
||||
id: productId,
|
||||
...payload,
|
||||
categoryId: payload.categoryId as Id<"categories">,
|
||||
})
|
||||
router.push("/products")
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to save product. Please try again.")
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
setIsArchiving(true)
|
||||
try {
|
||||
await archiveProduct({ id: productId })
|
||||
router.push("/products")
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to archive product.")
|
||||
setIsArchiving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (product === undefined) {
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-7 w-20" />
|
||||
<Skeleton className="h-7 w-48" />
|
||||
</div>
|
||||
<div className="max-w-2xl space-y-8">
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Skeleton className="h-9 w-full" />
|
||||
<Skeleton className="h-9 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
// Not found
|
||||
if (product === null) {
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href="/products"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "w-fit -ml-2")}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} />
|
||||
Products
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold">Product not found</h1>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This product does not exist or you do not have permission to view it.
|
||||
</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const defaultValues: Partial<ProductFormValues> = {
|
||||
name: product.name ?? "",
|
||||
slug: product.slug ?? "",
|
||||
status: product.status ?? "draft",
|
||||
categoryId: product.categoryId ?? "",
|
||||
tags: (product.tags ?? []).join(", "),
|
||||
shortDescription: product.shortDescription ?? "",
|
||||
description: product.description ?? "",
|
||||
brand: product.brand ?? "",
|
||||
petSize: (product.attributes?.petSize ?? []).join(", "),
|
||||
ageRange: (product.attributes?.ageRange ?? []).join(", "),
|
||||
specialDiet: (product.attributes?.specialDiet ?? []).join(", "),
|
||||
material: product.attributes?.material ?? "",
|
||||
flavor: product.attributes?.flavor ?? "",
|
||||
seoTitle: product.seoTitle ?? "",
|
||||
seoDescription: product.seoDescription ?? "",
|
||||
canonicalSlug: product.canonicalSlug ?? "",
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href="/products"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "w-fit -ml-2")}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} />
|
||||
Products
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold">{product.name}</h1>
|
||||
</div>
|
||||
|
||||
{product.status !== "archived" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => setArchiveOpen(true)}
|
||||
>
|
||||
<HugeiconsIcon icon={Archive01Icon} strokeWidth={2} />
|
||||
Archive
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="max-w-2xl">
|
||||
<ProductForm
|
||||
mode="edit"
|
||||
categories={categories}
|
||||
defaultValues={defaultValues}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={archiveOpen} onOpenChange={setArchiveOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Archive “{product.name}”?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This product will no longer appear on the storefront. You can
|
||||
restore it by editing the product status later.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={handleArchive}
|
||||
disabled={isArchiving}
|
||||
>
|
||||
{isArchiving ? "Archiving…" : "Archive"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
71
apps/admin/src/app/(dashboard)/products/new/page.tsx
Normal file
71
apps/admin/src/app/(dashboard)/products/new/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQuery, useMutation } from "convex/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { api } from "../../../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../../../convex/_generated/dataModel"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
ProductForm,
|
||||
buildProductPayload,
|
||||
type ProductFormValues,
|
||||
} from "@/components/products/ProductForm"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function NewProductPage() {
|
||||
const router = useRouter()
|
||||
const categories = useQuery(api.categories.list, {}) ?? []
|
||||
const createProduct = useMutation(api.products.create)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(values: ProductFormValues) {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const payload = buildProductPayload(values)
|
||||
await createProduct({
|
||||
...payload,
|
||||
categoryId: payload.categoryId as Id<"categories">,
|
||||
})
|
||||
router.push("/products")
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to create product. Please try again.")
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href="/products"
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "w-fit -ml-2")}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} />
|
||||
Products
|
||||
</Link>
|
||||
<h1 className="text-xl font-semibold">New product</h1>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="max-w-2xl">
|
||||
<ProductForm
|
||||
mode="create"
|
||||
categories={categories}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
471
apps/admin/src/app/(dashboard)/products/page.tsx
Normal file
471
apps/admin/src/app/(dashboard)/products/page.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { usePaginatedQuery, useQuery } from "convex/react"
|
||||
import { api } from "../../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../../convex/_generated/dataModel"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import {
|
||||
Search01Icon,
|
||||
Cancel01Icon,
|
||||
ArrowUpDownIcon,
|
||||
SortByUp01Icon,
|
||||
SortByDown01Icon,
|
||||
AddCircleIcon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
import {
|
||||
ProductPreviewDialog,
|
||||
type PreviewProduct,
|
||||
} from "@/components/products/ProductPreviewDialog"
|
||||
import { ProductActionsMenu } from "@/components/products/ProductActionsMenu"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type SortField = "name" | "brand" | "childCategory"
|
||||
type SortOrder = "asc" | "desc"
|
||||
type ColumnKey = "brand" | "childCategory" | "status" | "slug" | "tags" | "updated"
|
||||
|
||||
const OPTIONAL_COLUMNS: { key: ColumnKey; label: string }[] = [
|
||||
{ key: "brand", label: "Brand" },
|
||||
{ key: "childCategory", label: "Category" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "slug", label: "Slug" },
|
||||
{ key: "tags", label: "Tags" },
|
||||
{ key: "updated", label: "Updated" },
|
||||
]
|
||||
|
||||
const DEFAULT_VISIBLE: Record<ColumnKey, boolean> = {
|
||||
brand: true,
|
||||
childCategory: true,
|
||||
status: true,
|
||||
slug: true,
|
||||
tags: false,
|
||||
updated: false,
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
active: { label: "Active", variant: "default" as const },
|
||||
draft: { label: "Draft", variant: "secondary" as const },
|
||||
archived: { label: "Archived", variant: "outline" as const },
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function SortableHeader({
|
||||
field,
|
||||
sortField,
|
||||
sortOrder,
|
||||
onSort,
|
||||
children,
|
||||
}: {
|
||||
field: SortField
|
||||
sortField: SortField | null
|
||||
sortOrder: SortOrder
|
||||
onSort: (field: SortField) => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const active = sortField === field
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={() => onSort(field)}
|
||||
>
|
||||
{children}
|
||||
<HugeiconsIcon
|
||||
icon={
|
||||
active
|
||||
? sortOrder === "asc"
|
||||
? SortByUp01Icon
|
||||
: SortByDown01Icon
|
||||
: ArrowUpDownIcon
|
||||
}
|
||||
strokeWidth={2}
|
||||
className={cn(
|
||||
"size-3.5",
|
||||
active ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function TableSkeleton({
|
||||
visibleCols,
|
||||
}: {
|
||||
visibleCols: Record<ColumnKey, boolean>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-36" />
|
||||
</TableCell>
|
||||
{visibleCols.brand && (
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleCols.childCategory && (
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleCols.status && (
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleCols.slug && (
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleCols.tags && (
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleCols.updated && (
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-28" />
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<Skeleton className="size-7 rounded-lg" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [visibleCols, setVisibleCols] =
|
||||
useState<Record<ColumnKey, boolean>>(DEFAULT_VISIBLE)
|
||||
const [sortField, setSortField] = useState<SortField | null>(null)
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||
const [previewProduct, setPreviewProduct] = useState<PreviewProduct | null>(null)
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
|
||||
// Debounce search input by 300ms
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearchQuery(searchInput), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [searchInput])
|
||||
|
||||
const isSearching = searchQuery.trim().length > 0
|
||||
|
||||
const {
|
||||
results: listResults,
|
||||
status: listStatus,
|
||||
loadMore,
|
||||
} = usePaginatedQuery(api.products.list, {}, { initialNumItems: 25 })
|
||||
|
||||
const searchResults = useQuery(
|
||||
api.products.search,
|
||||
isSearching ? { query: searchQuery, limit: 100 } : "skip",
|
||||
)
|
||||
|
||||
const isLoading = isSearching
|
||||
? searchResults === undefined
|
||||
: listStatus === "LoadingFirstPage"
|
||||
|
||||
const rawProducts = isSearching ? (searchResults ?? []) : listResults
|
||||
|
||||
const products = useMemo(() => {
|
||||
if (!sortField) return rawProducts
|
||||
return [...rawProducts].sort((a: any, b: any) => {
|
||||
const aVal: string =
|
||||
sortField === "name"
|
||||
? (a.name ?? "")
|
||||
: sortField === "brand"
|
||||
? (a.brand ?? "")
|
||||
: (a.childCategorySlug ?? "")
|
||||
const bVal: string =
|
||||
sortField === "name"
|
||||
? (b.name ?? "")
|
||||
: sortField === "brand"
|
||||
? (b.brand ?? "")
|
||||
: (b.childCategorySlug ?? "")
|
||||
return sortOrder === "asc"
|
||||
? aVal.localeCompare(bVal)
|
||||
: bVal.localeCompare(aVal)
|
||||
})
|
||||
}, [rawProducts, sortField, sortOrder])
|
||||
|
||||
function handleSort(field: SortField) {
|
||||
if (sortField === field) {
|
||||
setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
|
||||
} else {
|
||||
setSortField(field)
|
||||
setSortOrder("asc")
|
||||
}
|
||||
}
|
||||
|
||||
function toggleColumn(key: ColumnKey, checked: boolean) {
|
||||
setVisibleCols((prev) => ({ ...prev, [key]: checked }))
|
||||
}
|
||||
|
||||
function openPreview(product: any) {
|
||||
setPreviewProduct(product as PreviewProduct)
|
||||
setPreviewOpen(true)
|
||||
}
|
||||
|
||||
const visibleOptionalCount = Object.values(visibleCols).filter(Boolean).length
|
||||
// Name + visible optional cols + Actions
|
||||
const totalColSpan = 1 + visibleOptionalCount + 1
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Products</h1>
|
||||
<Link href="/products/new" className={buttonVariants()}>
|
||||
<HugeiconsIcon icon={AddCircleIcon} strokeWidth={2} />
|
||||
New product
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<HugeiconsIcon
|
||||
icon={Search01Icon}
|
||||
strokeWidth={2}
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search products…"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="pl-8 pr-8"
|
||||
/>
|
||||
{searchInput && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSearchInput("")}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={Cancel01Icon}
|
||||
strokeWidth={2}
|
||||
className="size-4"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" size="sm" />}>
|
||||
Columns
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{OPTIONAL_COLUMNS.map(({ key, label }) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={key}
|
||||
checked={visibleCols[key]}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
toggleColumn(key, checked)
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">
|
||||
<SortableHeader
|
||||
field="name"
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleSort}
|
||||
>
|
||||
Name
|
||||
</SortableHeader>
|
||||
</TableHead>
|
||||
{visibleCols.brand && (
|
||||
<TableHead scope="col">
|
||||
<SortableHeader
|
||||
field="brand"
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleSort}
|
||||
>
|
||||
Brand
|
||||
</SortableHeader>
|
||||
</TableHead>
|
||||
)}
|
||||
{visibleCols.childCategory && (
|
||||
<TableHead scope="col">
|
||||
<SortableHeader
|
||||
field="childCategory"
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleSort}
|
||||
>
|
||||
Category
|
||||
</SortableHeader>
|
||||
</TableHead>
|
||||
)}
|
||||
{visibleCols.status && (
|
||||
<TableHead scope="col">Status</TableHead>
|
||||
)}
|
||||
{visibleCols.slug && <TableHead scope="col">Slug</TableHead>}
|
||||
{visibleCols.tags && <TableHead scope="col">Tags</TableHead>}
|
||||
{visibleCols.updated && (
|
||||
<TableHead scope="col">Updated</TableHead>
|
||||
)}
|
||||
<TableHead scope="col" className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableSkeleton visibleCols={visibleCols} />
|
||||
) : products.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={totalColSpan}
|
||||
className="py-16 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{isSearching
|
||||
? `No products match "${searchQuery}".`
|
||||
: "No products yet. Create your first product to get started."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
products.map((product: any) => {
|
||||
const statusCfg =
|
||||
STATUS_CONFIG[product.status as keyof typeof STATUS_CONFIG]
|
||||
return (
|
||||
<TableRow key={product._id}>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium hover:underline text-left"
|
||||
onClick={() => openPreview(product)}
|
||||
>
|
||||
{product.name}
|
||||
</button>
|
||||
</TableCell>
|
||||
{visibleCols.brand && (
|
||||
<TableCell className="text-muted-foreground">
|
||||
{product.brand ?? "—"}
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleCols.childCategory && (
|
||||
<TableCell className="text-muted-foreground">
|
||||
{product.childCategorySlug ?? "—"}
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleCols.status && (
|
||||
<TableCell>
|
||||
{statusCfg ? (
|
||||
<Badge variant={statusCfg.variant}>
|
||||
{statusCfg.label}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{product.status}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleCols.slug && (
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{product.slug}
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleCols.tags && (
|
||||
<TableCell className="text-muted-foreground">
|
||||
{product.tags?.length > 0
|
||||
? product.tags.join(", ")
|
||||
: "—"}
|
||||
</TableCell>
|
||||
)}
|
||||
{visibleCols.updated && (
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{product.updatedAt
|
||||
? new Date(product.updatedAt).toLocaleDateString()
|
||||
: "—"}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<ProductActionsMenu
|
||||
productId={product._id as Id<"products">}
|
||||
productName={product.name}
|
||||
isArchived={product.status === "archived"}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination footer — list mode only */}
|
||||
{!isSearching && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{listStatus === "Exhausted"
|
||||
? `${listResults.length} product${listResults.length !== 1 ? "s" : ""} total`
|
||||
: `${listResults.length} loaded`}
|
||||
</span>
|
||||
{listStatus === "CanLoadMore" && (
|
||||
<Button variant="outline" size="sm" onClick={() => loadMore(25)}>
|
||||
Load more
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProductPreviewDialog
|
||||
product={previewProduct}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
144
apps/admin/src/app/(dashboard)/variant/page.tsx
Normal file
144
apps/admin/src/app/(dashboard)/variant/page.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { api } from "../../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../../convex/_generated/dataModel"
|
||||
import { ProductSearchSection } from "../../../components/shared/ProductSearchSection"
|
||||
import { VariantsTable, VariantsTableSkeleton, type Variant } from "../../../components/variants/VariantsTable"
|
||||
import { VariantPreviewDialog } from "../../../components/variants/VariantPreviewDialog"
|
||||
import { CreateVariantDialog } from "../../../components/variants/CreateVariantDialog"
|
||||
import { EditVariantDialog } from "../../../components/variants/EditVariantDialog"
|
||||
import { Separator } from "../../../components/ui/separator"
|
||||
import { Button } from "../../../components/ui/button"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
} from "../../../components/ui/table"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { AddCircleIcon } from "@hugeicons/core-free-icons"
|
||||
|
||||
interface SelectedProduct {
|
||||
_id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function VariantsPage() {
|
||||
const [selectedProduct, setSelectedProduct] = useState<SelectedProduct | null>(null)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editVariant, setEditVariant] = useState<Variant | null>(null)
|
||||
const [previewVariant, setPreviewVariant] = useState<Variant | null>(null)
|
||||
|
||||
const productData = useQuery(
|
||||
api.products.getByIdForAdmin,
|
||||
selectedProduct ? { id: selectedProduct._id as Id<"products"> } : "skip",
|
||||
)
|
||||
|
||||
function handleProductSelect(product: SelectedProduct) {
|
||||
setSelectedProduct(product)
|
||||
}
|
||||
|
||||
function handleSearchClear() {
|
||||
setSelectedProduct(null)
|
||||
}
|
||||
|
||||
const variants = (productData?.variants ?? []) as Variant[]
|
||||
const isLoading = selectedProduct !== null && productData === undefined
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 flex-col gap-6 p-4 pt-0">
|
||||
<h1 className="text-xl font-semibold">Variants</h1>
|
||||
|
||||
{/* Product search */}
|
||||
<section className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Select a product</p>
|
||||
<ProductSearchSection
|
||||
onSelect={handleProductSelect}
|
||||
onClear={handleSearchClear}
|
||||
selectedId={selectedProduct?._id}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* No product selected */}
|
||||
{!selectedProduct && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Search and select a product to manage its variants.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Product selected */}
|
||||
{selectedProduct && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<section className="space-y-4">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{selectedProduct.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isLoading
|
||||
? "Loading variants…"
|
||||
: `${variants.length} variant${variants.length !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
{!isLoading && (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<HugeiconsIcon icon={AddCircleIcon} strokeWidth={2} />
|
||||
Create variant
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{isLoading && (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableBody>
|
||||
<VariantsTableSkeleton />
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Variants table */}
|
||||
{!isLoading && productData && (
|
||||
<VariantsTable
|
||||
variants={variants}
|
||||
onPreview={(v) => setPreviewVariant(v)}
|
||||
onEdit={(v) => setEditVariant(v)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Preview dialog */}
|
||||
<VariantPreviewDialog
|
||||
variant={previewVariant}
|
||||
open={previewVariant !== null}
|
||||
onOpenChange={(open) => { if (!open) setPreviewVariant(null) }}
|
||||
/>
|
||||
|
||||
{/* Create dialog */}
|
||||
{selectedProduct && (
|
||||
<CreateVariantDialog
|
||||
productId={selectedProduct._id as Id<"products">}
|
||||
productName={selectedProduct.name}
|
||||
brand={productData?.brand}
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit dialog */}
|
||||
<EditVariantDialog
|
||||
variant={editVariant}
|
||||
productName={selectedProduct?.name ?? ""}
|
||||
brand={productData?.brand}
|
||||
open={editVariant !== null}
|
||||
onOpenChange={(open) => { if (!open) setEditVariant(null) }}
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
53
apps/admin/src/app/api/upload-image/route.ts
Normal file
53
apps/admin/src/app/api/upload-image/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { v2 as cloudinary } from "cloudinary"
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
cloudinary.config({
|
||||
cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
|
||||
api_key: process.env.NEXT_PUBLIC_CLOUDINARY_API_KEY,
|
||||
api_secret: process.env.CLOUDINARY_API_SECRET,
|
||||
})
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const formData = await req.formData()
|
||||
const file = formData.get("file") as File | null
|
||||
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "No file provided" }, { status: 400 })
|
||||
}
|
||||
|
||||
const productId = formData.get("productId") as string | null
|
||||
const rawPosition = formData.get("position") as string | null
|
||||
|
||||
// Build a structured public_id when productId + position are provided.
|
||||
// position 0 → …/main, position N → …/gallery-N
|
||||
let publicId: string | undefined
|
||||
let assetFolder: string | undefined
|
||||
if (productId && rawPosition !== null) {
|
||||
const position = parseInt(rawPosition, 10)
|
||||
const slot = position === 0 ? "main" : `gallery-${position}`
|
||||
publicId = `the-pet-loft/products/${productId}/${slot}`
|
||||
assetFolder = `the-pet-loft/products/${productId}`
|
||||
}
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
|
||||
try {
|
||||
const result = await new Promise<{ secure_url: string }>((resolve, reject) => {
|
||||
cloudinary.uploader
|
||||
.upload_stream(
|
||||
{ public_id: publicId, asset_folder: assetFolder, resource_type: "image", overwrite: true },
|
||||
(error, res) => {
|
||||
if (error || !res) reject(error ?? new Error("Upload failed"))
|
||||
else resolve(res as { secure_url: string })
|
||||
},
|
||||
)
|
||||
.end(buffer)
|
||||
})
|
||||
|
||||
return NextResponse.json({ url: result.secure_url })
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Upload failed"
|
||||
return NextResponse.json({ error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
126
apps/admin/src/app/globals.css
Normal file
126
apps/admin/src/app/globals.css
Normal file
@@ -0,0 +1,126 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.60 0.10 185);
|
||||
--primary-foreground: oklch(0.98 0.01 181);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.58 0.22 27);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.85 0.13 181);
|
||||
--chart-2: oklch(0.78 0.13 182);
|
||||
--chart-3: oklch(0.70 0.12 183);
|
||||
--chart-4: oklch(0.60 0.10 185);
|
||||
--chart-5: oklch(0.51 0.09 186);
|
||||
--radius: 0.45rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.60 0.10 185);
|
||||
--sidebar-primary-foreground: oklch(0.98 0.01 181);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.70 0.12 183);
|
||||
--primary-foreground: oklch(0.28 0.04 193);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.85 0.13 181);
|
||||
--chart-2: oklch(0.78 0.13 182);
|
||||
--chart-3: oklch(0.70 0.12 183);
|
||||
--chart-4: oklch(0.60 0.10 185);
|
||||
--chart-5: oklch(0.51 0.09 186);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.78 0.13 182);
|
||||
--sidebar-primary-foreground: oklch(0.28 0.04 193);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
45
apps/admin/src/app/layout.tsx
Normal file
45
apps/admin/src/app/layout.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from "next";
|
||||
import { DM_Sans, Geist, Geist_Mono } from "next/font/google";
|
||||
import { ClerkProvider } from "@clerk/nextjs";
|
||||
import { ConvexClientProvider } from "@repo/convex";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const dmSans = DM_Sans({subsets:['latin'],variable:'--font-sans'});
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | Admin",
|
||||
default: "Admin Dashboard",
|
||||
},
|
||||
description: "Store administration",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={ dmSans.className}>
|
||||
<ClerkProvider>
|
||||
<ConvexClientProvider>
|
||||
{children}
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</ConvexClientProvider>
|
||||
</ClerkProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
48
apps/admin/src/app/not-found.tsx
Normal file
48
apps/admin/src/app/not-found.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="flex flex-1 items-center justify-center bg-[var(--background)] px-4 py-16 md:px-6 md:py-24">
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-8 md:flex-row md:gap-12">
|
||||
<div className="flex w-full flex-col items-center text-center md:w-1/2 md:items-start md:text-left">
|
||||
<h2 className="text-2xl font-black tracking-tight md:text-3xl flex flex-wrap gap-x-3 gap-y-0 items-baseline">
|
||||
<span className="font-extralight">Page</span>
|
||||
<span>not found</span>
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 max-w-md text-base leading-relaxed md:text-lg">
|
||||
Sorry, we couldn't find the page you're looking for. It
|
||||
may have been moved, renamed, or no longer exists.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-8 inline-flex items-center rounded-full bg-primary px-8 py-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 md:w-auto"
|
||||
>
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="order-first flex w-full max-w-xs flex-col items-center gap-2 md:order-last md:w-1/2 md:max-w-none">
|
||||
<Image
|
||||
src="/illustrations/404_not_found.svg"
|
||||
alt="Page not found illustration"
|
||||
width={500}
|
||||
height={500}
|
||||
priority
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
<a
|
||||
href="https://storyset.com/online"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-[var(--muted)] hover:underline"
|
||||
>
|
||||
Online illustrations by Storyset
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
33
apps/admin/src/components/auth/AccessDenied.tsx
Normal file
33
apps/admin/src/components/auth/AccessDenied.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
interface AccessDeniedProps {
|
||||
reason: "not_authenticated" | "not_admin" | "no_user_record";
|
||||
onSignOut: () => void;
|
||||
}
|
||||
|
||||
export function AccessDenied({ onSignOut }: AccessDeniedProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Access Denied</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
You don't have permission to access the admin dashboard.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onSignOut}
|
||||
className="rounded-md bg-gray-900 px-4 py-2 text-sm text-white hover:bg-gray-700"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
<a
|
||||
href={process.env.NEXT_PUBLIC_STOREFRONT_URL ?? "/"}
|
||||
className="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Go to storefront
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
apps/admin/src/components/auth/AdminAuthGate.tsx
Normal file
21
apps/admin/src/components/auth/AdminAuthGate.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useAdminAuth } from "../../hooks/useAdminAuth";
|
||||
import { useClerk } from "@clerk/nextjs";
|
||||
import { LoadingSkeleton } from "./LoadingSkeleton";
|
||||
import { AccessDenied } from "./AccessDenied";
|
||||
|
||||
export function AdminAuthGate({ children }: { children: React.ReactNode }) {
|
||||
const auth = useAdminAuth();
|
||||
const { signOut } = useClerk();
|
||||
|
||||
if (auth.status === "loading") {
|
||||
return <LoadingSkeleton />;
|
||||
}
|
||||
|
||||
if (auth.status === "denied") {
|
||||
return <AccessDenied reason={auth.reason} onSignOut={() => signOut()} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
9
apps/admin/src/components/auth/AdminUserSync.tsx
Normal file
9
apps/admin/src/components/auth/AdminUserSync.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useStoreUserEffect } from "../../hooks/useStoreUserEffect";
|
||||
|
||||
|
||||
export function AdminUserSync() {
|
||||
useStoreUserEffect();
|
||||
return null;
|
||||
}
|
||||
12
apps/admin/src/components/auth/LoadingSkeleton.tsx
Normal file
12
apps/admin/src/components/auth/LoadingSkeleton.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
export function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-gray-600" />
|
||||
<p className="text-sm text-gray-500">Verifying access...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
apps/admin/src/components/auth/RequireRole.tsx
Normal file
16
apps/admin/src/components/auth/RequireRole.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useAdminAuth } from "../../hooks/useAdminAuth";
|
||||
|
||||
interface RequireRoleProps {
|
||||
role: "super_admin";
|
||||
fallback?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function RequireRole({ role, fallback = null, children }: RequireRoleProps) {
|
||||
const auth = useAdminAuth();
|
||||
if (auth.status !== "authorized") return null;
|
||||
if (auth.role !== role) return <>{fallback}</>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
284
apps/admin/src/components/images/ImageUploadSection.tsx
Normal file
284
apps/admin/src/components/images/ImageUploadSection.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
import { api } from "../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../convex/_generated/dataModel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { ImageUpload01Icon } from "@hugeicons/core-free-icons"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ImageUploadSectionProps {
|
||||
productId: Id<"products">
|
||||
nextPosition: number
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function ImageUploadSection({
|
||||
productId,
|
||||
nextPosition,
|
||||
onSuccess,
|
||||
}: ImageUploadSectionProps) {
|
||||
const [originalFile, setOriginalFile] = useState<File | null>(null)
|
||||
const [localUrl, setLocalUrl] = useState<string | null>(null)
|
||||
const [processedBlob, setProcessedBlob] = useState<Blob | null>(null)
|
||||
const [processedUrl, setProcessedUrl] = useState<string | null>(null)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSubmittingOriginal, setIsSubmittingOriginal] = useState(false)
|
||||
const [processingError, setProcessingError] = useState<string | null>(null)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const addImage = useMutation(api.products.addImage)
|
||||
|
||||
async function processFile(file: File) {
|
||||
if (localUrl) URL.revokeObjectURL(localUrl)
|
||||
if (processedUrl) URL.revokeObjectURL(processedUrl)
|
||||
|
||||
setOriginalFile(file)
|
||||
setLocalUrl(URL.createObjectURL(file))
|
||||
setProcessedBlob(null)
|
||||
setProcessedUrl(null)
|
||||
setProcessingError(null)
|
||||
setUploadError(null)
|
||||
setIsProcessing(true)
|
||||
|
||||
const baseUrl =
|
||||
process.env.NEXT_PUBLIC_IMAGE_PROCESSING_API_URL ?? "http://localhost:8000"
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
formData.append("format", "webp")
|
||||
formData.append("quality", "95")
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/remove-background`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errJson = await res.json().catch(() => ({}))
|
||||
throw new Error((errJson as any).detail ?? "Background removal failed")
|
||||
}
|
||||
|
||||
const blob = await res.blob()
|
||||
setProcessedBlob(blob)
|
||||
setProcessedUrl(URL.createObjectURL(blob))
|
||||
} catch (err) {
|
||||
setProcessingError(err instanceof Error ? err.message : "Processing failed")
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) processFile(file)
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file && file.type.startsWith("image/")) processFile(file)
|
||||
}
|
||||
|
||||
async function uploadBlob(blob: Blob, filename: string) {
|
||||
const uploadForm = new FormData()
|
||||
uploadForm.append("file", blob, filename)
|
||||
uploadForm.append("productId", productId)
|
||||
uploadForm.append("position", String(nextPosition))
|
||||
|
||||
const uploadRes = await fetch("/api/upload-image", {
|
||||
method: "POST",
|
||||
body: uploadForm,
|
||||
})
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
const errJson = await uploadRes.json().catch(() => ({}))
|
||||
throw new Error((errJson as any).error ?? "Upload failed")
|
||||
}
|
||||
|
||||
const { url } = await uploadRes.json()
|
||||
return url as string
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!processedBlob) return
|
||||
setIsSubmitting(true)
|
||||
setUploadError(null)
|
||||
|
||||
try {
|
||||
const url = await uploadBlob(processedBlob, "product-image.webp")
|
||||
await addImage({ productId, url, position: nextPosition })
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : "Upload failed")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmitOriginal() {
|
||||
if (!originalFile) return
|
||||
setIsSubmittingOriginal(true)
|
||||
setUploadError(null)
|
||||
|
||||
try {
|
||||
const url = await uploadBlob(originalFile, originalFile.name)
|
||||
await addImage({ productId, url, position: nextPosition })
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setUploadError(err instanceof Error ? err.message : "Upload failed")
|
||||
} finally {
|
||||
setIsSubmittingOriginal(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border border-dashed p-8 transition-colors",
|
||||
isDragging
|
||||
? "border-foreground/60 bg-muted"
|
||||
: "hover:border-foreground/40 hover:bg-muted/50",
|
||||
)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Select or drop an image file"
|
||||
onKeyDown={(e) => e.key === "Enter" && fileInputRef.current?.click()}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ImageUpload01Icon}
|
||||
strokeWidth={2}
|
||||
className="size-8 text-muted-foreground"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">Click to select or drag and drop</p>
|
||||
<p className="text-xs text-muted-foreground">PNG, JPEG, WebP</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="sr-only"
|
||||
onChange={handleFileChange}
|
||||
aria-label="Image file input"
|
||||
/>
|
||||
|
||||
{/* Side-by-side previews */}
|
||||
{(localUrl || isProcessing) && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Original</p>
|
||||
{localUrl && (
|
||||
<div className="aspect-square overflow-hidden rounded-md border bg-muted">
|
||||
<img src={localUrl} alt="Original" className="h-full w-full object-contain" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">Background removed</p>
|
||||
{isProcessing ? (
|
||||
<Skeleton className="aspect-square w-full rounded-md" />
|
||||
) : processedUrl ? (
|
||||
<div className="aspect-square overflow-hidden rounded-md border bg-[conic-gradient(#e5e7eb_25%,_#fff_25%,_#fff_50%,_#e5e7eb_50%,_#e5e7eb_75%,_#fff_75%)] bg-[length:16px_16px]">
|
||||
<img
|
||||
src={processedUrl}
|
||||
alt="Background removed"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : processingError ? (
|
||||
<div className="flex aspect-square items-center justify-center rounded-md border bg-muted p-4">
|
||||
<p className="text-center text-xs text-destructive">{processingError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadError && <p className="text-sm text-destructive">{uploadError}</p>}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!processedBlob || isSubmitting || isSubmittingOriginal}
|
||||
>
|
||||
{isSubmitting && (
|
||||
<svg
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isSubmitting ? "Uploading…" : "Upload processed"}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSubmitOriginal}
|
||||
disabled={!originalFile || isSubmitting || isSubmittingOriginal}
|
||||
>
|
||||
{isSubmittingOriginal && (
|
||||
<svg
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isSubmittingOriginal ? "Uploading…" : "Upload original"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
242
apps/admin/src/components/images/ProductImageCarousel.tsx
Normal file
242
apps/admin/src/components/images/ProductImageCarousel.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
KeyboardSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core"
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
horizontalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { useMutation } from "convex/react"
|
||||
import { api } from "../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../convex/_generated/dataModel"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { Delete02Icon, ImageAdd01Icon, DragDropVerticalIcon } from "@hugeicons/core-free-icons"
|
||||
|
||||
interface ProductImage {
|
||||
_id: Id<"productImages">
|
||||
url: string
|
||||
alt?: string
|
||||
position: number
|
||||
}
|
||||
|
||||
// ─── Drag handle ──────────────────────────────────────────────────────────────
|
||||
// Separate component following the user's pattern: calls useSortable with the
|
||||
// same id to get attributes + listeners for the drag trigger element only.
|
||||
|
||||
function DragHandle({ id }: { id: string }) {
|
||||
const { attributes, listeners } = useSortable({ id })
|
||||
return (
|
||||
<Button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 cursor-grab text-muted-foreground hover:bg-transparent active:cursor-grabbing"
|
||||
>
|
||||
<HugeiconsIcon icon={DragDropVerticalIcon} strokeWidth={2} className="size-3.5" />
|
||||
<span className="sr-only">Drag to reorder</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Sortable image card ───────────────────────────────────────────────────────
|
||||
// Each card is a single-column "table" with 3 rows, physically rotated 180°.
|
||||
// The DOM order is [delete, image, drag handle]; after rotation the visual
|
||||
// order becomes [drag handle, image, delete] — drag handle on top, delete on
|
||||
// bottom — matching the desired layout.
|
||||
|
||||
function SortableImageCard({
|
||||
image,
|
||||
onDelete,
|
||||
}: {
|
||||
image: ProductImage
|
||||
onDelete: (id: Id<"productImages">) => void
|
||||
}) {
|
||||
const { setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: image._id,
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
zIndex: isDragging ? 10 : undefined,
|
||||
}
|
||||
|
||||
return (
|
||||
// Outer div: dnd-kit positioning (no rotation here to keep transforms clean)
|
||||
<div ref={setNodeRef} style={style} className="shrink-0">
|
||||
{/* Inner column: rotated 180° — children appear in reverse visual order */}
|
||||
<div className="flex rotate-180 flex-col items-center gap-1">
|
||||
{/* Row 3 in DOM → Row 1 visually (bottom in DOM = top after rotation) */}
|
||||
{/* Delete button — appears at BOTTOM after rotation */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Delete image"
|
||||
className="size-7 rotate-180 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDelete(image._id)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} strokeWidth={2} className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
{/* Row 2 in DOM → Row 2 visually (middle stays middle) */}
|
||||
{/* Image — appears in CENTER */}
|
||||
<div className="w-28 aspect-square rotate-180 overflow-hidden rounded-md border bg-muted">
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.alt ?? "Product image"}
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 1 in DOM → Row 3 visually (top in DOM = bottom after rotation) */}
|
||||
{/* Drag handle — appears at TOP after rotation */}
|
||||
<div className="rotate-180">
|
||||
<DragHandle id={image._id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Gallery ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ProductImageCarouselProps {
|
||||
images: ProductImage[]
|
||||
onAddMore: () => void
|
||||
}
|
||||
|
||||
export function ProductImageCarousel({ images, onAddMore }: ProductImageCarouselProps) {
|
||||
const [sortedImages, setSortedImages] = useState<ProductImage[]>(() =>
|
||||
[...images].sort((a, b) => a.position - b.position),
|
||||
)
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<Id<"productImages"> | null>(null)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const deleteImage = useMutation(api.products.deleteImage)
|
||||
const reorderImages = useMutation(api.products.reorderImages)
|
||||
|
||||
// Sync with server whenever images prop changes (after add/delete)
|
||||
useEffect(() => {
|
||||
setSortedImages([...images].sort((a, b) => a.position - b.position))
|
||||
}, [images])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
setSortedImages((prev) => {
|
||||
const oldIndex = prev.findIndex((img) => img._id === active.id)
|
||||
const newIndex = prev.findIndex((img) => img._id === over.id)
|
||||
const reordered = arrayMove(prev, oldIndex, newIndex)
|
||||
reorderImages({
|
||||
updates: reordered.map((img, i) => ({ id: img._id, position: i })),
|
||||
})
|
||||
return reordered
|
||||
})
|
||||
}
|
||||
|
||||
async function handleConfirmDelete() {
|
||||
if (!pendingDeleteId) return
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteImage({ id: pendingDeleteId })
|
||||
setPendingDeleteId(null)
|
||||
} catch (err) {
|
||||
console.error("Failed to delete image:", err)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start gap-3 overflow-x-auto pb-2">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortedImages.map((img) => img._id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{sortedImages.map((image) => (
|
||||
<SortableImageCard
|
||||
key={image._id}
|
||||
image={image}
|
||||
onDelete={setPendingDeleteId}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Add more — outside sortable context, always at the end */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddMore}
|
||||
className="flex w-28 shrink-0 flex-col items-center justify-center gap-2 self-center rounded-md border border-dashed p-6 text-sm text-muted-foreground transition-colors hover:border-foreground/40 hover:bg-muted/50 hover:text-foreground"
|
||||
style={{ aspectRatio: "1" }}
|
||||
>
|
||||
<HugeiconsIcon icon={ImageAdd01Icon} strokeWidth={2} className="size-5" />
|
||||
Add image
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={pendingDeleteId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingDeleteId(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this image?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This cannot be undone. The image will be permanently removed from this product.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting…" : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
104
apps/admin/src/components/images/ProductSearchSection.tsx
Normal file
104
apps/admin/src/components/images/ProductSearchSection.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { api } from "../../../../../convex/_generated/api"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { Search01Icon, Cancel01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
interface SearchProduct {
|
||||
_id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ProductSearchSectionProps {
|
||||
onSelect: (product: SearchProduct) => void
|
||||
onClear: () => void
|
||||
selectedId?: string
|
||||
}
|
||||
|
||||
export function ProductSearchSection({ onSelect, onClear, selectedId }: ProductSearchSectionProps) {
|
||||
const [input, setInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setQuery(input), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [input])
|
||||
|
||||
const isSearching = query.trim().length > 0
|
||||
|
||||
const results = useQuery(
|
||||
api.products.search,
|
||||
isSearching ? { query, limit: 3 } : "skip",
|
||||
)
|
||||
|
||||
const isLoading = isSearching && results === undefined
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative max-w-sm">
|
||||
<HugeiconsIcon
|
||||
icon={Search01Icon}
|
||||
strokeWidth={2}
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search products…"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value)
|
||||
if (e.target.value === "") onClear()
|
||||
}}
|
||||
className="pl-8 pr-8"
|
||||
/>
|
||||
{input && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setInput("")
|
||||
setQuery("")
|
||||
onClear()
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="max-w-sm space-y-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-9 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && isSearching && results && results.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No products match “{query}”.</p>
|
||||
)}
|
||||
|
||||
{!isLoading && results && results.length > 0 && (
|
||||
<ul className="max-w-sm divide-y rounded-md border">
|
||||
{results.map((product: any) => (
|
||||
<li key={product._id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect({ _id: product._id, name: product.name })}
|
||||
className={`w-full px-3 py-2 text-left text-sm transition-colors hover:bg-muted ${
|
||||
selectedId === product._id ? "bg-muted font-medium" : ""
|
||||
}`}
|
||||
>
|
||||
{product.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
apps/admin/src/components/layout/DynamicBreadcrumb.tsx
Normal file
67
apps/admin/src/components/layout/DynamicBreadcrumb.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Home01Icon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { ROUTE_LABELS } from "@/lib/constants/app.constants";
|
||||
|
||||
export function DynamicBreadcrumb() {
|
||||
const pathname = usePathname();
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
|
||||
// Root path — render "Dashboard" as a single page crumb
|
||||
if (segments.length === 0) {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Dashboard</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{/* Home icon link */}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink render={<Link href="/" />}>
|
||||
<HugeiconsIcon icon={Home01Icon} size={16} />
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
{segments.map((segment, index) => {
|
||||
const href = "/" + segments.slice(0, index + 1).join("/");
|
||||
const label = ROUTE_LABELS[segment] ?? segment;
|
||||
const isLast = index === segments.length - 1;
|
||||
|
||||
return (
|
||||
<span key={href} className="contents">
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>{label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink render={<Link href={href} />}>
|
||||
{label}
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
58
apps/admin/src/components/layout/sidebar/app-sidebar.tsx
Normal file
58
apps/admin/src/components/layout/sidebar/app-sidebar.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Store01Icon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenu,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { NavMain } from "./nav-main";
|
||||
import { NavUser } from "./nav-user";
|
||||
import { NAV_LINKS } from "@/lib/constants/app.constants";
|
||||
import { PawPrint } from "lucide-react";
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" tooltip="The Pet Loft Admin">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<PawPrint className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">The Pet Loft</span>
|
||||
<span className="truncate text-xs text-muted-foreground">Admin</span>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
{/* dashboard */}
|
||||
<NavMain overview={NAV_LINKS.overview} navMain={[]} isOverview={true} />
|
||||
|
||||
{/* Application */}
|
||||
<NavMain navMain={NAV_LINKS.navMain} overview={[]} isOverview={false} />
|
||||
|
||||
{/* Users */}
|
||||
<NavMain overview={NAV_LINKS.users} navMain={[]} isOverview={true} />
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
113
apps/admin/src/components/layout/sidebar/nav-main.tsx
Normal file
113
apps/admin/src/components/layout/sidebar/nav-main.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
|
||||
import { ArrowRight01Icon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { CollapsibleTrigger, CollapsibleContent, Collapsible } from "@/components/ui/collapsible";
|
||||
|
||||
type NavItem = {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: IconSvgElement;
|
||||
items?: { title: string; url: string }[];
|
||||
};
|
||||
|
||||
function NavMainItem({ item, pathname }: { item: NavItem; pathname: string }) {
|
||||
const isGroupActive = pathname.startsWith(item.url);
|
||||
const [open, setOpen] = useState(isGroupActive);
|
||||
|
||||
// Auto-open when navigating into this group's routes
|
||||
useEffect(() => {
|
||||
if (isGroupActive) setOpen(true);
|
||||
}, [isGroupActive]);
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<Collapsible open={open} onOpenChange={setOpen} className="group/collapsible">
|
||||
<CollapsibleTrigger
|
||||
render={
|
||||
<SidebarMenuButton tooltip={item.title} isActive={isGroupActive} />
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={item.icon} size={16} />
|
||||
<span>{item.title}</span>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowRight01Icon}
|
||||
className="ml-auto transition-transform duration-200 group-data-[open]/collapsible:rotate-90"
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton
|
||||
render={<Link href={subItem.url} />}
|
||||
isActive={pathname === subItem.url}
|
||||
>
|
||||
<span>{subItem.title}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function NavMain({
|
||||
overview,
|
||||
isOverview,
|
||||
navMain,
|
||||
}: {
|
||||
overview: Omit<NavItem, "items">[];
|
||||
isOverview?: boolean;
|
||||
navMain: NavItem[];
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (isOverview) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Platform</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{overview.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
render={<Link href={item.url} />}
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url}
|
||||
>
|
||||
<HugeiconsIcon icon={item.icon} size={16} />
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Application</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{navMain.map((item) => (
|
||||
<NavMainItem key={item.title} item={item} pathname={pathname} />
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
25
apps/admin/src/components/layout/sidebar/nav-user.tsx
Normal file
25
apps/admin/src/components/layout/sidebar/nav-user.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
import { UserButton } from "@clerk/nextjs";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
|
||||
export function NavUser() {
|
||||
const { user } = useUser();
|
||||
const { state } = useSidebar();
|
||||
const isCollapsed = state === "collapsed";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<UserButton afterSignOutUrl="/sign-in" />
|
||||
{!isCollapsed && (
|
||||
<div className="grid flex-1 text-left text-sm leading-tight overflow-hidden">
|
||||
<span className="truncate font-medium">{user?.fullName}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user?.primaryEmailAddress?.emailAddress}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useAction } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "../../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../../convex/_generated/dataModel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface Props {
|
||||
orderId: Id<"orders">
|
||||
}
|
||||
|
||||
export function AcceptReturnButton({ orderId }: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const acceptReturn = useAction(api.returnActions.acceptReturn)
|
||||
|
||||
async function handleClick() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await acceptReturn({ orderId })
|
||||
if (result.success) {
|
||||
toast.success(`Return accepted. Tracking: ${result.returnTrackingNumber}`)
|
||||
} else {
|
||||
toast.error((result as { success: false; code: string; message: string }).message)
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to accept return.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={handleClick} disabled={isLoading} variant="outline">
|
||||
{isLoading && (
|
||||
<svg
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isLoading ? "Accepting…" : "Accept Return"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useAction } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "../../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../../convex/_generated/dataModel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface Props {
|
||||
orderId: Id<"orders">
|
||||
}
|
||||
|
||||
export function CreateLabelButton({ orderId }: Props) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const createLabel = useAction(api.fulfillmentActions.createShippingLabel)
|
||||
|
||||
async function handleClick() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const result = await createLabel({ orderId })
|
||||
if (result.success) {
|
||||
toast.success(`Label created. Tracking: ${result.trackingNumber}`)
|
||||
} else {
|
||||
toast.error((result as { success: false; code: string; message: string }).message)
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to create shipping label.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button onClick={handleClick} disabled={isLoading}>
|
||||
{isLoading && (
|
||||
<svg
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isLoading ? "Creating…" : "Create Label"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useAction } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { formatPrice } from "@repo/utils"
|
||||
import { api } from "../../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../../convex/_generated/dataModel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
interface Props {
|
||||
orderId: Id<"orders">
|
||||
total: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export function IssueRefundButton({ orderId, total, currency }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const issueRefund = useAction(api.returnActions.issueRefund)
|
||||
|
||||
const formattedTotal = formatPrice(total, currency.toUpperCase())
|
||||
|
||||
async function handleConfirm() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await issueRefund({ orderId })
|
||||
toast.success("Refund issued.")
|
||||
setOpen(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to issue refund.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="destructive" onClick={() => setOpen(true)}>
|
||||
Issue Refund
|
||||
</Button>
|
||||
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Issue full refund?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
A full refund of {formattedTotal} will be sent to the customer via
|
||||
Stripe. This cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && (
|
||||
<svg
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isLoading ? "Refunding…" : "Issue Refund"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "../../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../../convex/_generated/dataModel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
interface Props {
|
||||
orderId: Id<"orders">
|
||||
}
|
||||
|
||||
export function MarkReturnReceivedButton({ orderId }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const markReceived = useMutation(api.orders.markReturnReceived)
|
||||
|
||||
async function handleConfirm() {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await markReceived({ id: orderId })
|
||||
toast.success("Return marked as received.")
|
||||
setOpen(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to mark return as received.")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setOpen(true)}>
|
||||
Mark Return Received
|
||||
</Button>
|
||||
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Mark return as received?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Confirm that the returned items have arrived. You can then issue a
|
||||
refund.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirm} disabled={isLoading}>
|
||||
{isLoading && (
|
||||
<svg
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isLoading ? "Saving…" : "Confirm"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
139
apps/admin/src/components/orders/actions/UpdateStatusDialog.tsx
Normal file
139
apps/admin/src/components/orders/actions/UpdateStatusDialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "../../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../../convex/_generated/dataModel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
ORDER_STATUS_CONFIG,
|
||||
type OrderStatus,
|
||||
} from "../shared/statusConfig"
|
||||
|
||||
interface Props {
|
||||
orderId: Id<"orders">
|
||||
currentStatus: string
|
||||
}
|
||||
|
||||
const ALL_STATUSES = Object.keys(ORDER_STATUS_CONFIG) as OrderStatus[]
|
||||
|
||||
export function UpdateStatusDialog({ orderId, currentStatus }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const availableStatuses = ALL_STATUSES.filter((s) => s !== currentStatus)
|
||||
const [selectedStatus, setSelectedStatus] = useState<string>(
|
||||
availableStatuses[0] ?? "",
|
||||
)
|
||||
|
||||
const updateStatus = useMutation(api.orders.updateStatus)
|
||||
|
||||
// Reset selection to first available when dialog opens
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (next) {
|
||||
setSelectedStatus(availableStatuses[0] ?? "")
|
||||
}
|
||||
setOpen(next)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!selectedStatus) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await updateStatus({
|
||||
id: orderId,
|
||||
status: selectedStatus as OrderStatus,
|
||||
})
|
||||
const label = ORDER_STATUS_CONFIG[selectedStatus as OrderStatus]?.label
|
||||
toast.success(`Status updated to ${label ?? selectedStatus}`)
|
||||
setOpen(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to update status.")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(true)}>
|
||||
Update Status
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update order status</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Select value={selectedStatus} onValueChange={setSelectedStatus}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableStatuses.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{ORDER_STATUS_CONFIG[s].label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!selectedStatus || isSubmitting}
|
||||
>
|
||||
{isSubmitting && (
|
||||
<svg
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isSubmitting ? "Updating…" : "Update"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
25
apps/admin/src/components/orders/detail/CustomerCard.tsx
Normal file
25
apps/admin/src/components/orders/detail/CustomerCard.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export function CustomerCard({ name, email }: Props) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Customer</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium">{name}</p>
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
className="text-sm text-muted-foreground hover:underline"
|
||||
>
|
||||
{email}
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
157
apps/admin/src/components/orders/detail/FulfilmentCard.tsx
Normal file
157
apps/admin/src/components/orders/detail/FulfilmentCard.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { ExternalLink } from "@hugeicons/core-free-icons"
|
||||
|
||||
interface Props {
|
||||
carrier: string
|
||||
shippingMethod: string
|
||||
shippingServiceCode: string
|
||||
trackingNumber?: string
|
||||
trackingUrl?: string
|
||||
labelUrl?: string
|
||||
returnLabelUrl?: string
|
||||
trackingStatus?: string
|
||||
estimatedDelivery?: number
|
||||
actualDelivery?: number
|
||||
status: string
|
||||
}
|
||||
|
||||
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 text-sm">
|
||||
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className="text-right">{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ExternalLinkIcon() {
|
||||
return (
|
||||
<HugeiconsIcon
|
||||
icon={ExternalLink}
|
||||
strokeWidth={2}
|
||||
className="inline size-3 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function FulfilmentCard({
|
||||
carrier,
|
||||
shippingMethod,
|
||||
shippingServiceCode,
|
||||
trackingNumber,
|
||||
trackingUrl,
|
||||
labelUrl,
|
||||
returnLabelUrl,
|
||||
trackingStatus,
|
||||
estimatedDelivery,
|
||||
actualDelivery,
|
||||
status,
|
||||
}: Props) {
|
||||
const isDelivered = status === "delivered" || !!actualDelivery
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Fulfilment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{/* ── State A & B: carrier info always shown ─────────────────────── */}
|
||||
{carrier && <InfoRow label="Carrier">{carrier}</InfoRow>}
|
||||
{shippingMethod && (
|
||||
<InfoRow label="Service">{shippingMethod}</InfoRow>
|
||||
)}
|
||||
{shippingServiceCode && !isDelivered && !trackingNumber && (
|
||||
<InfoRow label="Method">{shippingServiceCode}</InfoRow>
|
||||
)}
|
||||
|
||||
{/* ── State A: no label yet ──────────────────────────────────────── */}
|
||||
{!trackingNumber && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
No label created yet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* ── State B & C: tracking info ─────────────────────────────────── */}
|
||||
{trackingNumber && (
|
||||
<>
|
||||
<InfoRow label="Tracking">
|
||||
<span className="font-mono text-xs">{trackingNumber}</span>
|
||||
{trackingUrl && (
|
||||
<a
|
||||
href={trackingUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-1.5 inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
Track <ExternalLinkIcon />
|
||||
</a>
|
||||
)}
|
||||
</InfoRow>
|
||||
|
||||
{trackingStatus && (
|
||||
<InfoRow label="Status">
|
||||
<span className="font-mono text-xs uppercase">
|
||||
{trackingStatus}
|
||||
</span>
|
||||
</InfoRow>
|
||||
)}
|
||||
|
||||
{/* State B: estimated delivery */}
|
||||
{!isDelivered && estimatedDelivery && (
|
||||
<InfoRow label="Est. delivery">
|
||||
{new Date(estimatedDelivery).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
})}
|
||||
</InfoRow>
|
||||
)}
|
||||
|
||||
{/* State C: actual delivery */}
|
||||
{isDelivered && actualDelivery && (
|
||||
<InfoRow label="Delivered">
|
||||
{new Date(actualDelivery).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</InfoRow>
|
||||
)}
|
||||
|
||||
{/* Label download — State B only */}
|
||||
{!isDelivered && labelUrl && (
|
||||
<InfoRow label={returnLabelUrl ? "Outbound label" : "Label"}>
|
||||
<a
|
||||
href={labelUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
Download <ExternalLinkIcon />
|
||||
</a>
|
||||
</InfoRow>
|
||||
)}
|
||||
|
||||
{/* Return label — shown when return has been accepted */}
|
||||
{returnLabelUrl && (
|
||||
<InfoRow label="Return label">
|
||||
<a
|
||||
href={returnLabelUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
Download <ExternalLinkIcon />
|
||||
</a>
|
||||
</InfoRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
75
apps/admin/src/components/orders/detail/OrderActionsBar.tsx
Normal file
75
apps/admin/src/components/orders/detail/OrderActionsBar.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { Id } from "../../../../../../convex/_generated/dataModel"
|
||||
import { CreateLabelButton } from "../actions/CreateLabelButton"
|
||||
import { AcceptReturnButton } from "../actions/AcceptReturnButton"
|
||||
import { MarkReturnReceivedButton } from "../actions/MarkReturnReceivedButton"
|
||||
import { IssueRefundButton } from "../actions/IssueRefundButton"
|
||||
|
||||
interface Props {
|
||||
orderId: Id<"orders">
|
||||
status: string
|
||||
paymentStatus: string
|
||||
trackingNumber?: string
|
||||
returnRequestedAt?: number
|
||||
returnReceivedAt?: number
|
||||
total: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export function OrderActionsBar({
|
||||
orderId,
|
||||
status,
|
||||
paymentStatus,
|
||||
returnRequestedAt,
|
||||
returnReceivedAt,
|
||||
total,
|
||||
currency,
|
||||
}: Props) {
|
||||
// confirmed + no return → create outbound label
|
||||
if (status === "confirmed" && !returnRequestedAt) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CreateLabelButton orderId={orderId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// cancelled → refund if not already refunded
|
||||
if (status === "cancelled" && paymentStatus !== "refunded") {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<IssueRefundButton orderId={orderId} total={total} currency={currency} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// delivered + return requested → accept return (create return label)
|
||||
if (status === "delivered" && returnRequestedAt) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<AcceptReturnButton orderId={orderId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// processing + return in transit (accepted but not received) → mark received
|
||||
if (status === "processing" && returnRequestedAt && !returnReceivedAt) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MarkReturnReceivedButton orderId={orderId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// completed → refund if not already refunded
|
||||
if (status === "completed" && paymentStatus !== "refunded") {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<IssueRefundButton orderId={orderId} total={total} currency={currency} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// All other states (pending, processing w/o return, shipped, delivered w/o return,
|
||||
// refunded, return status) → no actions
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { formatPrice } from "@repo/utils"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
interface Props {
|
||||
subtotal: number
|
||||
shipping: number
|
||||
discount: number
|
||||
tax: number
|
||||
total: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function OrderFinancialsCard({
|
||||
subtotal,
|
||||
shipping,
|
||||
discount,
|
||||
tax,
|
||||
total,
|
||||
currency,
|
||||
}: Props) {
|
||||
const curr = currency.toUpperCase()
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Financials</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<InfoRow label="Subtotal" value={formatPrice(subtotal, curr)} />
|
||||
<InfoRow label="Shipping" value={formatPrice(shipping, curr)} />
|
||||
{discount > 0 && (
|
||||
<InfoRow
|
||||
label="Discount"
|
||||
value={`-${formatPrice(discount, curr)}`}
|
||||
/>
|
||||
)}
|
||||
{tax > 0 && <InfoRow label="Tax" value={formatPrice(tax, curr)} />}
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between text-sm font-semibold">
|
||||
<span>Total</span>
|
||||
<span>{formatPrice(total, curr)}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
85
apps/admin/src/components/orders/detail/OrderItemsCard.tsx
Normal file
85
apps/admin/src/components/orders/detail/OrderItemsCard.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { formatPrice } from "@repo/utils"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
interface OrderItem {
|
||||
_id: string
|
||||
productName: string
|
||||
variantName: string
|
||||
sku: string
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
totalPrice: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: OrderItem[]
|
||||
currency: string
|
||||
}
|
||||
|
||||
export function OrderItemsCard({ items, currency }: Props) {
|
||||
const curr = currency.toUpperCase()
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Order items</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="border-t">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">Product</TableHead>
|
||||
<TableHead scope="col">SKU</TableHead>
|
||||
<TableHead scope="col" className="text-right">
|
||||
Qty
|
||||
</TableHead>
|
||||
<TableHead scope="col" className="text-right">
|
||||
Unit
|
||||
</TableHead>
|
||||
<TableHead scope="col" className="text-right">
|
||||
Total
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item) => (
|
||||
<TableRow key={item._id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{item.productName}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.variantName}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{item.sku}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{item.quantity}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatPrice(item.unitPrice, curr)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatPrice(item.totalPrice, curr)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
50
apps/admin/src/components/orders/detail/OrderPageHeader.tsx
Normal file
50
apps/admin/src/components/orders/detail/OrderPageHeader.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import Link from "next/link"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons"
|
||||
import { OrderStatusBadge } from "../shared/OrderStatusBadge"
|
||||
import { OrderPaymentBadge } from "../shared/OrderPaymentBadge"
|
||||
|
||||
interface Props {
|
||||
orderNumber: string
|
||||
createdAt: number
|
||||
status: string
|
||||
paymentStatus: string
|
||||
}
|
||||
|
||||
export function OrderPageHeader({
|
||||
orderNumber,
|
||||
createdAt,
|
||||
status,
|
||||
paymentStatus,
|
||||
}: Props) {
|
||||
const formattedDate = new Date(createdAt).toLocaleDateString("en-GB", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Link
|
||||
href="/orders"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost", size: "sm" }),
|
||||
"-ml-2 w-fit",
|
||||
)}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} />
|
||||
Orders
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="font-mono text-xl font-semibold">{orderNumber}</h1>
|
||||
<OrderStatusBadge status={status} />
|
||||
<OrderPaymentBadge status={paymentStatus} />
|
||||
<span className="text-sm text-muted-foreground">{formattedDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
177
apps/admin/src/components/orders/detail/OrderTimelineCard.tsx
Normal file
177
apps/admin/src/components/orders/detail/OrderTimelineCard.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
EVENT_TYPE_LABELS,
|
||||
TIMELINE_DOT_COLOR,
|
||||
type OrderEventType,
|
||||
type OrderStatus,
|
||||
} from "../shared/statusConfig"
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TimelineEvent {
|
||||
_id: string
|
||||
eventType: string
|
||||
source: string
|
||||
fromStatus?: string
|
||||
toStatus?: string
|
||||
payload?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
events: TimelineEvent[] | undefined
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function parsePayload(raw: string | undefined): Record<string, string> {
|
||||
if (!raw) return {}
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function getEventLabel(event: TimelineEvent): string {
|
||||
switch (event.eventType as OrderEventType) {
|
||||
case "status_change": {
|
||||
const from = event.fromStatus ?? "—"
|
||||
const to = event.toStatus ?? "—"
|
||||
return `Status changed: ${from} → ${to}`
|
||||
}
|
||||
case "label_created": {
|
||||
const p = parsePayload(event.payload)
|
||||
const suffix = p.trackingNumber ? ` · ${p.trackingNumber}` : ""
|
||||
return `${EVENT_TYPE_LABELS.label_created}${suffix}`
|
||||
}
|
||||
case "tracking_update": {
|
||||
const p = parsePayload(event.payload)
|
||||
const s = p.status ?? event.toStatus ?? ""
|
||||
return s
|
||||
? `${EVENT_TYPE_LABELS.tracking_update}: ${s}`
|
||||
: EVENT_TYPE_LABELS.tracking_update
|
||||
}
|
||||
default: {
|
||||
const label = EVENT_TYPE_LABELS[event.eventType as OrderEventType]
|
||||
return label ?? event.eventType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDotColor(event: TimelineEvent): string {
|
||||
if (event.eventType === "status_change" && event.toStatus) {
|
||||
return (
|
||||
TIMELINE_DOT_COLOR[event.toStatus as OrderStatus] ??
|
||||
"text-muted-foreground"
|
||||
)
|
||||
}
|
||||
return "text-muted-foreground"
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
admin: "by admin",
|
||||
stripe_webhook: "via payment",
|
||||
customer_cancel: "by customer",
|
||||
customer_return: "by customer",
|
||||
shippo_webhook: "via courier",
|
||||
fulfillment: "by system",
|
||||
}
|
||||
|
||||
function formatEventDate(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
const date = d.toLocaleDateString("en-GB", { day: "numeric", month: "short" })
|
||||
const time = d.toLocaleTimeString("en-GB", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
return `${date} · ${time}`
|
||||
}
|
||||
|
||||
// ─── Skeleton ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function TimelineSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3">
|
||||
<Skeleton className="mt-0.5 size-3 shrink-0 rounded-full" />
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-3 w-20 shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function OrderTimelineCard({ events }: Props) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timeline</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{events === undefined ? (
|
||||
<TimelineSkeleton />
|
||||
) : events.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No activity yet.</p>
|
||||
) : (
|
||||
<ol className="flex flex-col">
|
||||
{events.map((event, index) => {
|
||||
const isLast = index === events.length - 1
|
||||
const dotColor = getDotColor(event)
|
||||
const sourceLabel =
|
||||
SOURCE_LABELS[event.source] ?? event.source
|
||||
|
||||
return (
|
||||
<li key={event._id} className="flex items-start gap-3">
|
||||
{/* Dot + vertical connector */}
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1 size-2.5 shrink-0 rounded-full border-2 border-current",
|
||||
dotColor,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{!isLast && (
|
||||
<span className="mt-1 w-px flex-1 bg-border" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Event content */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 items-start justify-between gap-4 pb-4",
|
||||
isLast && "pb-0",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-sm">{getEventLabel(event)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sourceLabel}
|
||||
</p>
|
||||
</div>
|
||||
<time
|
||||
dateTime={new Date(event.createdAt).toISOString()}
|
||||
className="shrink-0 text-xs text-muted-foreground"
|
||||
>
|
||||
{formatEventDate(event.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
interface AddressSnapshot {
|
||||
fullName: string
|
||||
addressLine1: string
|
||||
additionalInformation?: string
|
||||
city: string
|
||||
postalCode: string
|
||||
country: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
address: AddressSnapshot
|
||||
}
|
||||
|
||||
export function ShippingAddressCard({ address }: Props) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Shipping address</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-0.5">
|
||||
<p className="text-sm font-medium">{address.fullName}</p>
|
||||
<p className="text-sm">{address.addressLine1}</p>
|
||||
{address.additionalInformation && (
|
||||
<p className="text-sm">{address.additionalInformation}</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
{address.city}, {address.postalCode}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{address.country}</p>
|
||||
{address.phone && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{address.phone}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PAYMENT_STATUS_CONFIG, type PaymentStatus } from "./statusConfig"
|
||||
|
||||
export function OrderPaymentBadge({ status }: { status: string }) {
|
||||
const config = PAYMENT_STATUS_CONFIG[status as PaymentStatus]
|
||||
|
||||
if (!config) {
|
||||
return <span className="text-xs text-muted-foreground">{status}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={config.variant} className={cn(config.className)}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
17
apps/admin/src/components/orders/shared/OrderStatusBadge.tsx
Normal file
17
apps/admin/src/components/orders/shared/OrderStatusBadge.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ORDER_STATUS_CONFIG, type OrderStatus } from "./statusConfig"
|
||||
|
||||
export function OrderStatusBadge({ status }: { status: string }) {
|
||||
const config = ORDER_STATUS_CONFIG[status as OrderStatus]
|
||||
|
||||
if (!config) {
|
||||
return <span className="text-xs text-muted-foreground">{status}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={config.variant} className={cn(config.className)}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
110
apps/admin/src/components/orders/shared/statusConfig.ts
Normal file
110
apps/admin/src/components/orders/shared/statusConfig.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type OrderStatus =
|
||||
| "pending"
|
||||
| "confirmed"
|
||||
| "processing"
|
||||
| "shipped"
|
||||
| "delivered"
|
||||
| "cancelled"
|
||||
| "refunded"
|
||||
| "return"
|
||||
| "completed"
|
||||
|
||||
export type PaymentStatus = "pending" | "paid" | "failed" | "refunded"
|
||||
|
||||
export type OrderEventType =
|
||||
| "status_change"
|
||||
| "label_created"
|
||||
| "tracking_update"
|
||||
| "customer_cancel"
|
||||
| "return_requested"
|
||||
| "return_received"
|
||||
| "return_accepted"
|
||||
| "refund"
|
||||
|
||||
type BadgeVariant = "default" | "secondary" | "destructive" | "outline"
|
||||
|
||||
interface StatusConfig {
|
||||
label: string
|
||||
variant: BadgeVariant
|
||||
className?: string
|
||||
}
|
||||
|
||||
// ─── Order status ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const ORDER_STATUS_CONFIG: Record<OrderStatus, StatusConfig> = {
|
||||
pending: { label: "Pending", variant: "secondary" },
|
||||
confirmed: {
|
||||
label: "Confirmed",
|
||||
variant: "outline",
|
||||
className: "border-blue-500 text-blue-600",
|
||||
},
|
||||
processing: {
|
||||
label: "Processing",
|
||||
variant: "outline",
|
||||
className: "border-violet-500 text-violet-600",
|
||||
},
|
||||
shipped: {
|
||||
label: "Shipped",
|
||||
variant: "outline",
|
||||
className: "border-indigo-500 text-indigo-600",
|
||||
},
|
||||
delivered: {
|
||||
label: "Delivered",
|
||||
variant: "default",
|
||||
className: "bg-green-600",
|
||||
},
|
||||
cancelled: { label: "Cancelled", variant: "destructive" },
|
||||
refunded: {
|
||||
label: "Refunded",
|
||||
variant: "secondary",
|
||||
className: "line-through",
|
||||
},
|
||||
return: {
|
||||
label: "Return Requested",
|
||||
variant: "outline",
|
||||
className: "border-orange-500 text-orange-600",
|
||||
},
|
||||
completed: {
|
||||
label: "Completed",
|
||||
variant: "default",
|
||||
className: "bg-teal-600",
|
||||
},
|
||||
}
|
||||
|
||||
// ─── Payment status ───────────────────────────────────────────────────────────
|
||||
|
||||
export const PAYMENT_STATUS_CONFIG: Record<PaymentStatus, StatusConfig> = {
|
||||
pending: { label: "Pending", variant: "secondary" },
|
||||
paid: { label: "Paid", variant: "default", className: "bg-green-600" },
|
||||
failed: { label: "Failed", variant: "destructive" },
|
||||
refunded: { label: "Refunded", variant: "secondary" },
|
||||
}
|
||||
|
||||
// ─── Timeline event labels ────────────────────────────────────────────────────
|
||||
|
||||
export const EVENT_TYPE_LABELS: Record<OrderEventType, string> = {
|
||||
status_change: "Status changed",
|
||||
label_created: "Shipping label created",
|
||||
tracking_update: "Tracking update",
|
||||
customer_cancel: "Customer requested cancellation",
|
||||
return_requested: "Customer requested return",
|
||||
return_received: "Return marked as received",
|
||||
return_accepted: "Return accepted — label created",
|
||||
refund: "Refund issued",
|
||||
}
|
||||
|
||||
// ─── Timeline dot color (status_change events only) ───────────────────────────
|
||||
// Neutral dot for all other event types.
|
||||
|
||||
export const TIMELINE_DOT_COLOR: Partial<Record<OrderStatus, string>> = {
|
||||
confirmed: "text-blue-500",
|
||||
processing: "text-violet-500",
|
||||
shipped: "text-indigo-500",
|
||||
delivered: "text-green-600",
|
||||
cancelled: "text-destructive",
|
||||
refunded: "text-muted-foreground",
|
||||
return: "text-orange-500",
|
||||
completed: "text-teal-600",
|
||||
}
|
||||
117
apps/admin/src/components/products/ProductActionsMenu.tsx
Normal file
117
apps/admin/src/components/products/ProductActionsMenu.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useMutation } from "convex/react"
|
||||
import { api } from "../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../convex/_generated/dataModel"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import {
|
||||
MoreVerticalIcon,
|
||||
PencilEdit01Icon,
|
||||
Archive01Icon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
|
||||
interface ProductActionsMenuProps {
|
||||
productId: Id<"products">
|
||||
productName: string
|
||||
isArchived?: boolean
|
||||
}
|
||||
|
||||
export function ProductActionsMenu({
|
||||
productId,
|
||||
productName,
|
||||
isArchived,
|
||||
}: ProductActionsMenuProps) {
|
||||
const [archiveOpen, setArchiveOpen] = useState(false)
|
||||
const [isArchiving, setIsArchiving] = useState(false)
|
||||
const archive = useMutation(api.products.archive)
|
||||
|
||||
async function handleArchive() {
|
||||
setIsArchiving(true)
|
||||
try {
|
||||
await archive({ id: productId })
|
||||
setArchiveOpen(false)
|
||||
} catch (e) {
|
||||
console.error("Failed to archive product:", e)
|
||||
} finally {
|
||||
setIsArchiving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Actions for ${productName}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={MoreVerticalIcon} strokeWidth={2} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem render={<Link href={`/products/${productId}/edit`} />}>
|
||||
<HugeiconsIcon icon={PencilEdit01Icon} strokeWidth={2} />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
{!isArchived && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setArchiveOpen(true)}
|
||||
>
|
||||
<HugeiconsIcon icon={Archive01Icon} strokeWidth={2} />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={archiveOpen} onOpenChange={setArchiveOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Archive “{productName}”?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This product will no longer appear on the storefront. You can
|
||||
restore it by editing the product later.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={handleArchive}
|
||||
disabled={isArchiving}
|
||||
>
|
||||
{isArchiving ? "Archiving…" : "Archive"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
576
apps/admin/src/components/products/ProductForm.tsx
Normal file
576
apps/admin/src/components/products/ProductForm.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { slugify } from "@repo/utils"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { ArrowDown01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1, "Slug is required")
|
||||
.regex(/^[a-z0-9-]+$/, "Lowercase letters, numbers, and hyphens only"),
|
||||
status: z.enum(["active", "draft", "archived"]),
|
||||
categoryId: z.string().min(1, "Category is required"),
|
||||
tags: z.string().default(""),
|
||||
shortDescription: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
brand: z.string().optional(),
|
||||
// Attributes — comma-separated for array fields
|
||||
petSize: z.string().optional(),
|
||||
ageRange: z.string().optional(),
|
||||
specialDiet: z.string().optional(),
|
||||
material: z.string().optional(),
|
||||
flavor: z.string().optional(),
|
||||
// SEO
|
||||
seoTitle: z.string().optional(),
|
||||
seoDescription: z.string().optional(),
|
||||
canonicalSlug: z.string().optional(),
|
||||
})
|
||||
|
||||
export type ProductFormValues = z.infer<typeof formSchema>
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CategoryOption = {
|
||||
_id: string
|
||||
name: string
|
||||
slug: string
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
interface ProductFormProps {
|
||||
mode: "create" | "edit"
|
||||
categories: CategoryOption[]
|
||||
defaultValues?: Partial<ProductFormValues>
|
||||
onSubmit: (values: ProductFormValues) => Promise<void>
|
||||
isSubmitting: boolean
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseCommaList(str?: string): string[] {
|
||||
if (!str?.trim()) return []
|
||||
return str
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function buildProductPayload(values: ProductFormValues) {
|
||||
const tags = parseCommaList(values.tags)
|
||||
|
||||
const petSize = parseCommaList(values.petSize)
|
||||
const ageRange = parseCommaList(values.ageRange)
|
||||
const specialDiet = parseCommaList(values.specialDiet)
|
||||
const material = values.material?.trim() || undefined
|
||||
const flavor = values.flavor?.trim() || undefined
|
||||
|
||||
const hasAttrs =
|
||||
petSize.length > 0 ||
|
||||
ageRange.length > 0 ||
|
||||
specialDiet.length > 0 ||
|
||||
material ||
|
||||
flavor
|
||||
|
||||
return {
|
||||
name: values.name,
|
||||
slug: values.slug,
|
||||
status: values.status,
|
||||
categoryId: values.categoryId,
|
||||
tags,
|
||||
description: values.description?.trim() || undefined,
|
||||
shortDescription: values.shortDescription?.trim() || undefined,
|
||||
brand: values.brand?.trim() || undefined,
|
||||
attributes: hasAttrs
|
||||
? {
|
||||
...(petSize.length > 0 && { petSize }),
|
||||
...(ageRange.length > 0 && { ageRange }),
|
||||
...(specialDiet.length > 0 && { specialDiet }),
|
||||
...(material && { material }),
|
||||
...(flavor && { flavor }),
|
||||
}
|
||||
: undefined,
|
||||
seoTitle: values.seoTitle?.trim() || undefined,
|
||||
seoDescription: values.seoDescription?.trim() || undefined,
|
||||
canonicalSlug: values.canonicalSlug?.trim() || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function ProductForm({
|
||||
mode,
|
||||
categories,
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: ProductFormProps) {
|
||||
const [slugManuallySet, setSlugManuallySet] = useState(mode === "edit")
|
||||
|
||||
const form = useForm<ProductFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
slug: "",
|
||||
status: "draft",
|
||||
categoryId: "",
|
||||
tags: "",
|
||||
shortDescription: "",
|
||||
description: "",
|
||||
brand: "",
|
||||
petSize: "",
|
||||
ageRange: "",
|
||||
specialDiet: "",
|
||||
material: "",
|
||||
flavor: "",
|
||||
seoTitle: "",
|
||||
seoDescription: "",
|
||||
canonicalSlug: "",
|
||||
...defaultValues,
|
||||
},
|
||||
})
|
||||
|
||||
// Auto-derive slug from name in create mode
|
||||
const nameValue = form.watch("name")
|
||||
useEffect(() => {
|
||||
if (!slugManuallySet && nameValue) {
|
||||
form.setValue("slug", slugify(nameValue), { shouldValidate: false })
|
||||
}
|
||||
}, [nameValue, slugManuallySet, form])
|
||||
|
||||
// Build category options with "Parent / Child" display labels
|
||||
const categoryOptions = useMemo(() => {
|
||||
const idToName: Record<string, string> = {}
|
||||
for (const cat of categories) idToName[cat._id] = cat.name
|
||||
return categories
|
||||
.filter((cat) => cat.parentId)
|
||||
.map((cat) => ({
|
||||
value: cat._id,
|
||||
label: `${idToName[cat.parentId!] ?? "?"} / ${cat.name}`,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
}, [categories])
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
{/* ── Core ───────────────────────────────────────────────────── */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Core
|
||||
</h2>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Royal Canin Adult Cat" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="royal-canin-adult-cat"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
setSlugManuallySet(true)
|
||||
field.onChange(e)
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
URL-safe identifier. Auto-generated from name; edit to
|
||||
override.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(v) => field.onChange(v)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="archived">Archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="categoryId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Category</FormLabel>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(v) => field.onChange(v)}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select category" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{categories.length === 0 ? (
|
||||
<SelectItem value="_loading" disabled>
|
||||
Loading…
|
||||
</SelectItem>
|
||||
) : (
|
||||
categoryOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tags"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Tags</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="cat-food, dry, indoor" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated list of tags.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Details ────────────────────────────────────────────────── */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Details
|
||||
</h2>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brand"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Brand</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Royal Canin" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="shortDescription"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Short description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="One or two sentences shown in listings…"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Detailed product description…"
|
||||
className="min-h-32 resize-y"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Attributes (collapsible) ────────────────────────────────── */}
|
||||
<Collapsible className="group/collapsible">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between py-1 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
|
||||
<span className="uppercase tracking-wide">Attributes</span>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDown01Icon}
|
||||
strokeWidth={2}
|
||||
className="size-4 transition-transform group-data-[open]/collapsible:rotate-180"
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="petSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Pet size</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="small, medium, large" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ageRange"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Age range</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="puppy, adult, senior" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="specialDiet"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Special diet</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="grain-free, hypoallergenic"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Comma-separated.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="material"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Material</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Nylon" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="flavor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Flavor</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Chicken" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── SEO / Advanced (collapsible) ───────────────────────────── */}
|
||||
<Collapsible className="group/collapsible">
|
||||
<CollapsibleTrigger className="flex w-full items-center justify-between py-1 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors">
|
||||
<span className="uppercase tracking-wide">Advanced / SEO</span>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDown01Icon}
|
||||
strokeWidth={2}
|
||||
className="size-4 transition-transform group-data-[open]/collapsible:rotate-180"
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="seoTitle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SEO title</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Overrides product name in search results"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="seoDescription"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SEO description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Meta description for search engines…"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="canonicalSlug"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Canonical slug</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Overrides slug for canonical URL"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* ── Actions ────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
<svg
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{isSubmitting
|
||||
? mode === "create"
|
||||
? "Creating…"
|
||||
: "Saving…"
|
||||
: mode === "create"
|
||||
? "Create product"
|
||||
: "Save changes"}
|
||||
</Button>
|
||||
<Link
|
||||
href="/products"
|
||||
className={buttonVariants({ variant: "outline" })}
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
231
apps/admin/src/components/products/ProductPreviewDialog.tsx
Normal file
231
apps/admin/src/components/products/ProductPreviewDialog.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
export type PreviewProduct = {
|
||||
_id: string
|
||||
name: string
|
||||
slug: string
|
||||
status: "active" | "draft" | "archived"
|
||||
description?: string
|
||||
shortDescription?: string
|
||||
brand?: string
|
||||
tags: string[]
|
||||
parentCategorySlug?: string
|
||||
childCategorySlug?: string
|
||||
topCategorySlug?: string
|
||||
attributes?: {
|
||||
petSize?: string[]
|
||||
ageRange?: string[]
|
||||
specialDiet?: string[]
|
||||
material?: string
|
||||
flavor?: string
|
||||
}
|
||||
seoTitle?: string
|
||||
seoDescription?: string
|
||||
canonicalSlug?: string
|
||||
createdAt?: number
|
||||
updatedAt?: number
|
||||
averageRating?: number
|
||||
reviewCount?: number
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
active: { label: "Active", variant: "default" as const },
|
||||
draft: { label: "Draft", variant: "secondary" as const },
|
||||
archived: { label: "Archived", variant: "outline" as const },
|
||||
}
|
||||
|
||||
function formatDate(ms?: number) {
|
||||
if (!ms) return "—"
|
||||
return new Date(ms).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
if (children == null || children === "") return null
|
||||
return (
|
||||
<div className="grid grid-cols-[128px_1fr] gap-2 py-0.5">
|
||||
<span className="pt-0.5 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="break-words text-sm">{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProductPreviewDialogProps {
|
||||
product: PreviewProduct | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function ProductPreviewDialog({
|
||||
product,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ProductPreviewDialogProps) {
|
||||
if (!product) return null
|
||||
|
||||
const statusCfg = STATUS_CONFIG[product.status]
|
||||
const attrs = product.attributes
|
||||
const hasAttrs =
|
||||
attrs &&
|
||||
((attrs.petSize?.length ?? 0) > 0 ||
|
||||
(attrs.ageRange?.length ?? 0) > 0 ||
|
||||
(attrs.specialDiet?.length ?? 0) > 0 ||
|
||||
attrs.material ||
|
||||
attrs.flavor)
|
||||
const hasSeo =
|
||||
product.seoTitle || product.seoDescription || product.canonicalSlug
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{product.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="space-y-3 py-1">
|
||||
{/* Core fields */}
|
||||
<div className="space-y-0.5">
|
||||
<InfoRow label="Status">
|
||||
<Badge variant={statusCfg.variant}>{statusCfg.label}</Badge>
|
||||
</InfoRow>
|
||||
<InfoRow label="Slug">{product.slug}</InfoRow>
|
||||
<InfoRow label="Brand">{product.brand}</InfoRow>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Category */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Category
|
||||
</p>
|
||||
<InfoRow label="Parent">{product.parentCategorySlug}</InfoRow>
|
||||
<InfoRow label="Child">{product.childCategorySlug}</InfoRow>
|
||||
<InfoRow label="Top">{product.topCategorySlug}</InfoRow>
|
||||
</div>
|
||||
|
||||
{(product.shortDescription || product.description) && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Description
|
||||
</p>
|
||||
<InfoRow label="Short">{product.shortDescription}</InfoRow>
|
||||
{product.description && (
|
||||
<div className="py-0.5">
|
||||
<p className="mb-1 text-xs text-muted-foreground">Full</p>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{product.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{product.tags.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<InfoRow label="Tags">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{product.tags.map((t) => (
|
||||
<Badge key={t} variant="secondary">
|
||||
{t}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</InfoRow>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasAttrs && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-0.5">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Attributes
|
||||
</p>
|
||||
{(attrs?.petSize?.length ?? 0) > 0 && (
|
||||
<InfoRow label="Pet size">{attrs!.petSize!.join(", ")}</InfoRow>
|
||||
)}
|
||||
{(attrs?.ageRange?.length ?? 0) > 0 && (
|
||||
<InfoRow label="Age range">{attrs!.ageRange!.join(", ")}</InfoRow>
|
||||
)}
|
||||
{(attrs?.specialDiet?.length ?? 0) > 0 && (
|
||||
<InfoRow label="Special diet">
|
||||
{attrs!.specialDiet!.join(", ")}
|
||||
</InfoRow>
|
||||
)}
|
||||
{attrs?.material && (
|
||||
<InfoRow label="Material">{attrs.material}</InfoRow>
|
||||
)}
|
||||
{attrs?.flavor && (
|
||||
<InfoRow label="Flavor">{attrs.flavor}</InfoRow>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasSeo && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-0.5">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
SEO
|
||||
</p>
|
||||
<InfoRow label="SEO title">{product.seoTitle}</InfoRow>
|
||||
<InfoRow label="SEO description">{product.seoDescription}</InfoRow>
|
||||
<InfoRow label="Canonical slug">{product.canonicalSlug}</InfoRow>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Meta */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Meta
|
||||
</p>
|
||||
<InfoRow label="Created">{formatDate(product.createdAt)}</InfoRow>
|
||||
<InfoRow label="Updated">{formatDate(product.updatedAt)}</InfoRow>
|
||||
{product.averageRating != null && (
|
||||
<InfoRow label="Rating">
|
||||
{product.averageRating.toFixed(1)} ({product.reviewCount ?? 0}{" "}
|
||||
reviews)
|
||||
</InfoRow>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter showCloseButton />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
104
apps/admin/src/components/shared/ProductSearchSection.tsx
Normal file
104
apps/admin/src/components/shared/ProductSearchSection.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useQuery } from "convex/react"
|
||||
import { api } from "../../../../../convex/_generated/api"
|
||||
import { Input } from "../ui/input"
|
||||
import { Skeleton } from "../ui/skeleton"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { Search01Icon, Cancel01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
interface SearchProduct {
|
||||
_id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface ProductSearchSectionProps {
|
||||
onSelect: (product: SearchProduct) => void
|
||||
onClear: () => void
|
||||
selectedId?: string
|
||||
}
|
||||
|
||||
export function ProductSearchSection({ onSelect, onClear, selectedId }: ProductSearchSectionProps) {
|
||||
const [input, setInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setQuery(input), 300)
|
||||
return () => clearTimeout(t)
|
||||
}, [input])
|
||||
|
||||
const isSearching = query.trim().length > 0
|
||||
|
||||
const results = useQuery(
|
||||
api.products.search,
|
||||
isSearching ? { query, limit: 3 } : "skip",
|
||||
)
|
||||
|
||||
const isLoading = isSearching && results === undefined
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="relative max-w-sm">
|
||||
<HugeiconsIcon
|
||||
icon={Search01Icon}
|
||||
strokeWidth={2}
|
||||
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search products…"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value)
|
||||
if (e.target.value === "") onClear()
|
||||
}}
|
||||
className="pl-8 pr-8"
|
||||
/>
|
||||
{input && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setInput("")
|
||||
setQuery("")
|
||||
onClear()
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="max-w-sm space-y-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-9 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && isSearching && results && results.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No products match “{query}”.</p>
|
||||
)}
|
||||
|
||||
{!isLoading && results && results.length > 0 && (
|
||||
<ul className="max-w-sm divide-y rounded-md border">
|
||||
{results.map((product: any) => (
|
||||
<li key={product._id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect({ _id: product._id, name: product.name })}
|
||||
className={`w-full px-3 py-2 text-left text-sm transition-colors hover:bg-muted ${
|
||||
selectedId === product._id ? "bg-muted font-medium" : ""
|
||||
}`}
|
||||
>
|
||||
{product.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function StillBuildingPlaceholder() {
|
||||
return (
|
||||
<main className="flex flex-1 items-center justify-center bg-[var(--background)] px-4 py-16 md:px-6 md:py-24">
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-8 md:flex-row md:gap-12">
|
||||
<div className="flex w-full flex-col items-center text-center md:w-1/2 md:items-start md:text-left">
|
||||
<h2 className="text-2xl font-black tracking-tight md:text-3xl flex flex-wrap gap-x-3 gap-y-0 items-baseline">
|
||||
<span className="font-extralight">Building</span>
|
||||
<span>in Progress</span>
|
||||
</h2>
|
||||
|
||||
<p className="mt-4 max-w-md text-base leading-relaxed md:text-lg">
|
||||
Something pawsome is coming to this page! We're still crafting
|
||||
it. Try poking around on other pages in the meantime.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-8 inline-flex items-center rounded-full bg-primary px-8 py-3 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 md:w-auto"
|
||||
>
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="order-first flex w-full max-w-xs flex-col items-center gap-2 md:order-last md:w-1/2 md:max-w-none">
|
||||
<Image
|
||||
src="/illustrations/still_building.svg"
|
||||
alt="Page still being built illustration"
|
||||
width={500}
|
||||
height={500}
|
||||
priority
|
||||
className="h-auto w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
187
apps/admin/src/components/ui/alert-dialog.tsx
Normal file
187
apps/admin/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Popup.Props & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Popup
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Close.Props &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Close
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
109
apps/admin/src/components/ui/avatar.tsx
Normal file
109
apps/admin/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
52
apps/admin/src/components/ui/badge.tsx
Normal file
52
apps/admin/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
125
apps/admin/src/components/ui/breadcrumb.tsx
Normal file
125
apps/admin/src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { ArrowRight01Icon, MoreHorizontalCircle01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="breadcrumb"
|
||||
data-slot="breadcrumb"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"a">) {
|
||||
return useRender({
|
||||
defaultTagName: "a",
|
||||
props: mergeProps<"a">(
|
||||
{
|
||||
className: cn("transition-colors hover:text-foreground", className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "breadcrumb-link",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} />
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex size-5 items-center justify-center [&>svg]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={MoreHorizontalCircle01Icon} strokeWidth={2} />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
60
apps/admin/src/components/ui/button.tsx
Normal file
60
apps/admin/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
103
apps/admin/src/components/ui/card.tsx
Normal file
103
apps/admin/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
243
apps/admin/src/components/ui/carousel.tsx
Normal file
243
apps/admin/src/components/ui/carousel.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute touch-manipulation rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft01Icon} strokeWidth={2} />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute touch-manipulation rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
useCarousel,
|
||||
}
|
||||
29
apps/admin/src/components/ui/checkbox.tsx
Normal file
29
apps/admin/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { Tick02Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
21
apps/admin/src/components/ui/collapsible.tsx
Normal file
21
apps/admin/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
"use client"
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||
|
||||
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
157
apps/admin/src/components/ui/dialog.tsx
Normal file
157
apps/admin/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { Cancel01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-base leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
270
apps/admin/src/components/ui/dropdown-menu.tsx
Normal file
270
apps/admin/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { ArrowRight01Icon, Tick02Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} />
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
136
apps/admin/src/components/ui/form.tsx
Normal file
136
apps/admin/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = { name: TName }
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue,
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = { id: string }
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue,
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div data-slot="form-item" className={cn("grid gap-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({ className, ...props }: React.ComponentProps<"label">) {
|
||||
const { error, formItemId } = useFormField()
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ children }: { children: React.ReactElement<React.HTMLAttributes<HTMLElement>> }) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
return React.cloneElement(children, {
|
||||
id: formItemId,
|
||||
"aria-describedby": !error
|
||||
? formDescriptionId
|
||||
: `${formDescriptionId} ${formMessageId}`,
|
||||
"aria-invalid": !!error,
|
||||
})
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
if (!body) return null
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
20
apps/admin/src/components/ui/input.tsx
Normal file
20
apps/admin/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
20
apps/admin/src/components/ui/label.tsx
Normal file
20
apps/admin/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
55
apps/admin/src/components/ui/scroll-area.tsx
Normal file
55
apps/admin/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
200
apps/admin/src/components/ui/select.tsx
Normal file
200
apps/admin/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { UnfoldMoreIcon, Tick02Icon, ArrowUp01Icon, ArrowDown01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<HugeiconsIcon icon={UnfoldMoreIcon} strokeWidth={2} className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={Tick02Icon} strokeWidth={2} className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowUp01Icon} strokeWidth={2} />
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} strokeWidth={2} />
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
25
apps/admin/src/components/ui/separator.tsx
Normal file
25
apps/admin/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
135
apps/admin/src/components/ui/sheet.tsx
Normal file
135
apps/admin/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { Cancel01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} strokeWidth={2} />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-base font-medium text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
724
apps/admin/src/components/ui/sidebar.tsx
Normal file
724
apps/admin/src/components/ui/sidebar.tsx
Normal file
@@ -0,0 +1,724 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { SidebarLeftIcon } from "@hugeicons/core-free-icons"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed"
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
openMobile: boolean
|
||||
setOpenMobile: (open: boolean) => void
|
||||
isMobile: boolean
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext)
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
const isMobile = useIsMobile()
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||
const open = openProp ?? _open
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState)
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||
}, [isMobile, setOpen, setOpenMobile])
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault()
|
||||
toggleSidebar()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [toggleSidebar])
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed"
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||
)
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
variant?: "sidebar" | "floating" | "inset"
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer hidden text-sidebar-foreground md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<HugeiconsIcon icon={SidebarLeftIcon} strokeWidth={2} />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("h-8 w-full bg-background shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
|
||||
return useRender({
|
||||
defaultTagName: "div",
|
||||
props: mergeProps<"div">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-label",
|
||||
sidebar: "group-label",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-action",
|
||||
sidebar: "group-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
render,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const { isMobile, state } = useSidebar()
|
||||
const comp = useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render: !tooltip ? render : <TooltipTrigger render={render} />,
|
||||
state: {
|
||||
slot: "sidebar-menu-button",
|
||||
sidebar: "menu-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
|
||||
if (!tooltip) {
|
||||
return comp
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
{comp}
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
render,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "button",
|
||||
props: mergeProps<"button">(
|
||||
{
|
||||
className: cn(
|
||||
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
showOnHover &&
|
||||
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-action",
|
||||
sidebar: "menu-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
render,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: useRender.ComponentProps<"a"> &
|
||||
React.ComponentProps<"a"> & {
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
return useRender({
|
||||
defaultTagName: "a",
|
||||
props: mergeProps<"a">(
|
||||
{
|
||||
className: cn(
|
||||
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
className
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-sub-button",
|
||||
sidebar: "menu-sub-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
}
|
||||
13
apps/admin/src/components/ui/skeleton.tsx
Normal file
13
apps/admin/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
32
apps/admin/src/components/ui/switch.tsx
Normal file
32
apps/admin/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: SwitchPrimitive.Root.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
116
apps/admin/src/components/ui/table.tsx
Normal file
116
apps/admin/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
18
apps/admin/src/components/ui/textarea.tsx
Normal file
18
apps/admin/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
66
apps/admin/src/components/ui/tooltip.tsx
Normal file
66
apps/admin/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 w-fit max-w-xs origin-(--transform-origin) rounded-md bg-foreground px-3 py-1.5 text-xs text-background data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
126
apps/admin/src/components/variants/CreateVariantDialog.tsx
Normal file
126
apps/admin/src/components/variants/CreateVariantDialog.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../convex/_generated/dataModel"
|
||||
import { dollarsToCents } from "@repo/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog"
|
||||
import { Button } from "../ui/button"
|
||||
import { ScrollArea } from "../ui/scroll-area"
|
||||
import { VariantForm, CREATE_DEFAULTS, type VariantFormValues } from "./VariantForm"
|
||||
|
||||
const FORM_ID = "create-variant-form"
|
||||
|
||||
interface CreateVariantDialogProps {
|
||||
productId: Id<"products">
|
||||
productName: string
|
||||
brand?: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function CreateVariantDialog({
|
||||
productId,
|
||||
productName,
|
||||
brand,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: CreateVariantDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const addVariant = useMutation(api.products.addVariant)
|
||||
|
||||
async function handleSubmit(values: VariantFormValues) {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const attrSize = values.attrSize?.trim() || undefined
|
||||
const attrFlavor = values.attrFlavor?.trim() || undefined
|
||||
const attrColor = values.attrColor?.trim() || undefined
|
||||
const hasAttrs = attrSize || attrFlavor || attrColor
|
||||
|
||||
await addVariant({
|
||||
productId,
|
||||
name: values.name,
|
||||
sku: values.sku,
|
||||
price: dollarsToCents(values.price),
|
||||
compareAtPrice: values.onSale && values.compareAtPrice != null
|
||||
? dollarsToCents(values.compareAtPrice)
|
||||
: undefined,
|
||||
stockQuantity: values.stockQuantity,
|
||||
isActive: values.isActive,
|
||||
weight: values.weight,
|
||||
weightUnit: values.weightUnit,
|
||||
attributes: hasAttrs
|
||||
? { size: attrSize, flavor: attrFlavor, color: attrColor }
|
||||
: undefined,
|
||||
length: values.length,
|
||||
width: values.width,
|
||||
height: values.height,
|
||||
dimensionUnit: values.dimensionUnit,
|
||||
})
|
||||
|
||||
toast.success(`Variant "${values.name}" created`)
|
||||
onOpenChange(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to create variant")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create variant</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[65vh] pr-1">
|
||||
<div className="pb-2">
|
||||
<VariantForm
|
||||
formId={FORM_ID}
|
||||
defaultValues={CREATE_DEFAULTS}
|
||||
onSubmit={handleSubmit}
|
||||
productName={productName}
|
||||
brand={brand}
|
||||
mode="create"
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
<svg
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
{isSubmitting ? "Creating…" : "Create variant"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
129
apps/admin/src/components/variants/EditVariantDialog.tsx
Normal file
129
apps/admin/src/components/variants/EditVariantDialog.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "../../../../../convex/_generated/api"
|
||||
import { dollarsToCents } from "@repo/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog"
|
||||
import { Button } from "../ui/button"
|
||||
import { ScrollArea } from "../ui/scroll-area"
|
||||
import { VariantForm, variantToFormValues, type VariantFormValues } from "./VariantForm"
|
||||
import type { Variant } from "./VariantsTable"
|
||||
|
||||
const FORM_ID = "edit-variant-form"
|
||||
|
||||
interface EditVariantDialogProps {
|
||||
variant: Variant | null
|
||||
productName: string
|
||||
brand?: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function EditVariantDialog({
|
||||
variant,
|
||||
productName,
|
||||
brand,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: EditVariantDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const updateVariant = useMutation(api.products.updateVariant)
|
||||
|
||||
async function handleSubmit(values: VariantFormValues) {
|
||||
if (!variant) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const attrSize = values.attrSize?.trim() || undefined
|
||||
const attrFlavor = values.attrFlavor?.trim() || undefined
|
||||
const attrColor = values.attrColor?.trim() || undefined
|
||||
const hasAttrs = attrSize || attrFlavor || attrColor
|
||||
|
||||
await updateVariant({
|
||||
id: variant._id,
|
||||
name: values.name,
|
||||
sku: values.sku,
|
||||
price: dollarsToCents(values.price),
|
||||
compareAtPrice: values.onSale && values.compareAtPrice != null
|
||||
? dollarsToCents(values.compareAtPrice)
|
||||
: undefined,
|
||||
stockQuantity: values.stockQuantity,
|
||||
isActive: values.isActive,
|
||||
weight: values.weight,
|
||||
weightUnit: values.weightUnit,
|
||||
attributes: hasAttrs
|
||||
? { size: attrSize, flavor: attrFlavor, color: attrColor }
|
||||
: undefined,
|
||||
length: values.length,
|
||||
width: values.width,
|
||||
height: values.height,
|
||||
dimensionUnit: values.dimensionUnit,
|
||||
})
|
||||
|
||||
toast.success(`"${values.name}" updated`)
|
||||
onOpenChange(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to update variant")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!variant) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit variant</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[65vh] pr-1">
|
||||
<div className="pb-2">
|
||||
<VariantForm
|
||||
formId={FORM_ID}
|
||||
defaultValues={variantToFormValues(variant)}
|
||||
onSubmit={handleSubmit}
|
||||
productName={productName}
|
||||
brand={brand}
|
||||
mode="edit"
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={FORM_ID} disabled={isSubmitting}>
|
||||
{isSubmitting && (
|
||||
<svg
|
||||
data-icon="inline-start"
|
||||
className="animate-spin"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
)}
|
||||
{isSubmitting ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
486
apps/admin/src/components/variants/VariantForm.tsx
Normal file
486
apps/admin/src/components/variants/VariantForm.tsx
Normal file
@@ -0,0 +1,486 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
import { centsToDollars, generateSku } from "@repo/utils"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "../ui/form"
|
||||
import { Input } from "../ui/input"
|
||||
import { Switch } from "../ui/switch"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../ui/select"
|
||||
import { Separator } from "../ui/separator"
|
||||
import type { Variant } from "./VariantsTable"
|
||||
|
||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const optionalPositiveNum = z.preprocess(
|
||||
(v) => (v === "" || v == null ? undefined : Number(v)),
|
||||
z.number().positive().optional(),
|
||||
) as z.ZodType<number | undefined>
|
||||
|
||||
export const variantFormSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
sku: z.string().min(1, "SKU is required"),
|
||||
price: z.coerce.number({ invalid_type_error: "Enter a valid price" }).positive("Price must be greater than 0"),
|
||||
stockQuantity: z.coerce.number({ invalid_type_error: "Enter a valid number" }).int().min(0, "Stock must be 0 or more"),
|
||||
isActive: z.boolean(),
|
||||
weight: z.coerce.number({ invalid_type_error: "Enter a valid weight" }).positive("Weight must be greater than 0"),
|
||||
weightUnit: z.enum(["g", "kg", "lb", "oz"]),
|
||||
onSale: z.boolean(),
|
||||
compareAtPrice: optionalPositiveNum,
|
||||
attrSize: z.string().optional(),
|
||||
attrFlavor: z.string().optional(),
|
||||
attrColor: z.string().optional(),
|
||||
length: optionalPositiveNum,
|
||||
width: optionalPositiveNum,
|
||||
height: optionalPositiveNum,
|
||||
dimensionUnit: z.enum(["cm", "in"]).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.onSale && !data.compareAtPrice) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["compareAtPrice"],
|
||||
message: "Compare at price is required when on sale",
|
||||
})
|
||||
}
|
||||
const hasDim = data.length != null || data.width != null || data.height != null
|
||||
if (hasDim) {
|
||||
if (data.length == null)
|
||||
ctx.addIssue({ code: "custom", path: ["length"], message: "Required when any dimension is set" })
|
||||
if (data.width == null)
|
||||
ctx.addIssue({ code: "custom", path: ["width"], message: "Required when any dimension is set" })
|
||||
if (data.height == null)
|
||||
ctx.addIssue({ code: "custom", path: ["height"], message: "Required when any dimension is set" })
|
||||
if (!data.dimensionUnit)
|
||||
ctx.addIssue({ code: "custom", path: ["dimensionUnit"], message: "Required when dimensions are set" })
|
||||
}
|
||||
})
|
||||
|
||||
export type VariantFormValues = z.infer<typeof variantFormSchema>
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function variantToFormValues(variant: Variant): VariantFormValues {
|
||||
return {
|
||||
name: variant.name,
|
||||
sku: variant.sku,
|
||||
price: centsToDollars(variant.price),
|
||||
stockQuantity: variant.stockQuantity,
|
||||
isActive: variant.isActive,
|
||||
weight: variant.weight,
|
||||
weightUnit: variant.weightUnit,
|
||||
onSale: variant.compareAtPrice != null,
|
||||
compareAtPrice: variant.compareAtPrice != null ? centsToDollars(variant.compareAtPrice) : undefined,
|
||||
attrSize: variant.attributes?.size ?? "",
|
||||
attrFlavor: variant.attributes?.flavor ?? "",
|
||||
attrColor: variant.attributes?.color ?? "",
|
||||
length: variant.length,
|
||||
width: variant.width,
|
||||
height: variant.height,
|
||||
dimensionUnit: variant.dimensionUnit ?? "cm",
|
||||
}
|
||||
}
|
||||
|
||||
export const CREATE_DEFAULTS: VariantFormValues = {
|
||||
name: "",
|
||||
sku: "",
|
||||
price: 0,
|
||||
stockQuantity: 0,
|
||||
isActive: true,
|
||||
weight: 0,
|
||||
weightUnit: "kg",
|
||||
onSale: false,
|
||||
compareAtPrice: undefined,
|
||||
attrSize: "",
|
||||
attrFlavor: "",
|
||||
attrColor: "",
|
||||
length: undefined,
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
dimensionUnit: "cm",
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface VariantFormProps {
|
||||
formId: string
|
||||
defaultValues: VariantFormValues
|
||||
onSubmit: (values: VariantFormValues) => Promise<void>
|
||||
productName?: string
|
||||
brand?: string
|
||||
/** In edit mode the SKU starts as manually set — auto-gen is off by default */
|
||||
mode?: "create" | "edit"
|
||||
}
|
||||
|
||||
export function VariantForm({
|
||||
formId,
|
||||
defaultValues,
|
||||
onSubmit,
|
||||
productName = "",
|
||||
brand = "",
|
||||
mode = "create",
|
||||
}: VariantFormProps) {
|
||||
const [skuManuallyEdited, setSkuManuallyEdited] = useState(mode === "edit")
|
||||
|
||||
const form = useForm<VariantFormValues>({
|
||||
resolver: zodResolver(variantFormSchema),
|
||||
defaultValues,
|
||||
})
|
||||
|
||||
// Reset when defaultValues change (e.g. switching from one variant to another)
|
||||
useEffect(() => {
|
||||
form.reset(defaultValues)
|
||||
setSkuManuallyEdited(mode === "edit")
|
||||
}, [defaultValues]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Auto-generate SKU from variant-level details only
|
||||
const [attrFlavor, attrSize, attrColor, weight, weightUnit] = form.watch([
|
||||
"attrFlavor",
|
||||
"attrSize",
|
||||
"attrColor",
|
||||
"weight",
|
||||
"weightUnit",
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (skuManuallyEdited) return
|
||||
const suggested = generateSku(
|
||||
brand,
|
||||
productName,
|
||||
{ flavor: attrFlavor, size: attrSize, color: attrColor },
|
||||
weight || undefined,
|
||||
weightUnit,
|
||||
)
|
||||
if (suggested) form.setValue("sku", suggested)
|
||||
}, [brand, productName, attrFlavor, attrSize, attrColor, weight, weightUnit, skuManuallyEdited]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onSale = form.watch("onSale")
|
||||
const length = form.watch("length")
|
||||
const width = form.watch("width")
|
||||
const height = form.watch("height")
|
||||
const showDimensionUnit = length != null || width != null || height != null
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(onSubmit)} className="space-y-5">
|
||||
|
||||
{/* ── Identity ─────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 items-start gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="500g bag" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sku"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>SKU</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="RC-ADG-5KG"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
setSkuManuallyEdited(true)
|
||||
field.onChange(e.target.value.toUpperCase())
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{skuManuallyEdited ? "Manually set." : "Auto-generated from details."}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Pricing ──────────────────────────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Pricing</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="price"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Price (£)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.01" min="0" placeholder="19.99" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="onSale"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col justify-end pb-1">
|
||||
<FormLabel>On sale</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.value ? "Yes" : "No"}
|
||||
</span>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{onSale && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="compareAtPrice"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Compare at price (£)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="24.99"
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>Original price shown as strikethrough.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Inventory ────────────────────────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Inventory</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="stockQuantity"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Stock quantity</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" min="0" step="1" placeholder="0" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col justify-end pb-1">
|
||||
<FormLabel>Active</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.value ? "Visible" : "Hidden"}
|
||||
</span>
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Shipping ─────────────────────────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Shipping</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="weight"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Weight</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" step="0.001" min="0" placeholder="0.5" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="weightUnit"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Unit</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="kg">kg</SelectItem>
|
||||
<SelectItem value="g">g</SelectItem>
|
||||
<SelectItem value="lb">lb</SelectItem>
|
||||
<SelectItem value="oz">oz</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dimensions */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{(["length", "width", "height"] as const).map((dim) => (
|
||||
<FormField
|
||||
key={dim}
|
||||
control={form.control}
|
||||
name={dim}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="capitalize">{dim}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
placeholder="—"
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{showDimensionUnit && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dimensionUnit"
|
||||
render={({ field }) => (
|
||||
<FormItem className="max-w-[120px]">
|
||||
<FormLabel>Dimension unit</FormLabel>
|
||||
<Select value={field.value ?? "cm"} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="cm">cm</SelectItem>
|
||||
<SelectItem value="in">in</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── Attributes (optional) ────────────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Attributes <span className="normal-case font-normal">(optional)</span>
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attrSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Size</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="500g" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attrFlavor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Flavor</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Chicken" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="attrColor"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Color</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Red" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
143
apps/admin/src/components/variants/VariantPreviewDialog.tsx
Normal file
143
apps/admin/src/components/variants/VariantPreviewDialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import { formatPrice } from "@repo/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog"
|
||||
import { Badge } from "../ui/badge"
|
||||
import { ScrollArea } from "../ui/scroll-area"
|
||||
import { Separator } from "../ui/separator"
|
||||
import type { Variant } from "./VariantsTable"
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
if (children == null || children === "") return null
|
||||
return (
|
||||
<div className="grid grid-cols-[140px_1fr] gap-2 py-0.5">
|
||||
<span className="pt-0.5 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="break-words text-sm">{children}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface VariantPreviewDialogProps {
|
||||
variant: Variant | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function VariantPreviewDialog({
|
||||
variant,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: VariantPreviewDialogProps) {
|
||||
if (!variant) return null
|
||||
|
||||
const hasDimensions =
|
||||
variant.length != null || variant.width != null || variant.height != null
|
||||
|
||||
const hasAttributes =
|
||||
variant.attributes?.size ||
|
||||
variant.attributes?.flavor ||
|
||||
variant.attributes?.color
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{variant.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
<div className="space-y-3 py-1">
|
||||
{/* Core */}
|
||||
<div className="space-y-0.5">
|
||||
<InfoRow label="SKU">
|
||||
<span className="font-mono text-xs">{variant.sku}</span>
|
||||
</InfoRow>
|
||||
<InfoRow label="Status">
|
||||
<Badge variant={variant.isActive ? "default" : "secondary"}>
|
||||
{variant.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</InfoRow>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Pricing
|
||||
</p>
|
||||
<InfoRow label="Price">{formatPrice(variant.price)}</InfoRow>
|
||||
<InfoRow label="Compare at">
|
||||
{variant.compareAtPrice ? formatPrice(variant.compareAtPrice) : "—"}
|
||||
</InfoRow>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Inventory */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Inventory
|
||||
</p>
|
||||
<InfoRow label="Stock quantity">{variant.stockQuantity}</InfoRow>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Shipping */}
|
||||
<div className="space-y-0.5">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Shipping
|
||||
</p>
|
||||
<InfoRow label="Weight">
|
||||
{variant.weight} {variant.weightUnit}
|
||||
</InfoRow>
|
||||
{hasDimensions && (
|
||||
<InfoRow label="Dimensions">
|
||||
{[variant.length, variant.width, variant.height]
|
||||
.map((d) => d ?? "?")
|
||||
.join(" × ")}{" "}
|
||||
{variant.dimensionUnit ?? "cm"}
|
||||
</InfoRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasAttributes && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-0.5">
|
||||
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Attributes
|
||||
</p>
|
||||
{variant.attributes?.size && (
|
||||
<InfoRow label="Size">{variant.attributes.size}</InfoRow>
|
||||
)}
|
||||
{variant.attributes?.flavor && (
|
||||
<InfoRow label="Flavor">{variant.attributes.flavor}</InfoRow>
|
||||
)}
|
||||
{variant.attributes?.color && (
|
||||
<InfoRow label="Color">{variant.attributes.color}</InfoRow>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter showCloseButton />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
259
apps/admin/src/components/variants/VariantsTable.tsx
Normal file
259
apps/admin/src/components/variants/VariantsTable.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useMutation } from "convex/react"
|
||||
import { toast } from "sonner"
|
||||
import { api } from "../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../convex/_generated/dataModel"
|
||||
import { formatPrice } from "@repo/utils"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../ui/table"
|
||||
import { Badge } from "../ui/badge"
|
||||
import { Button } from "../ui/button"
|
||||
import { Skeleton } from "../ui/skeleton"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "../ui/alert-dialog"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import {
|
||||
MoreVerticalIcon,
|
||||
PencilEdit01Icon,
|
||||
Delete01Icon,
|
||||
CheckmarkCircle01Icon,
|
||||
Cancel01Icon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
|
||||
export interface Variant {
|
||||
_id: Id<"productVariants">
|
||||
name: string
|
||||
sku: string
|
||||
price: number
|
||||
compareAtPrice?: number
|
||||
stockQuantity: number
|
||||
isActive: boolean
|
||||
weight: number
|
||||
weightUnit: "g" | "kg" | "lb" | "oz"
|
||||
attributes?: { size?: string; flavor?: string; color?: string }
|
||||
length?: number
|
||||
width?: number
|
||||
height?: number
|
||||
dimensionUnit?: "cm" | "in"
|
||||
}
|
||||
|
||||
interface VariantsTableProps {
|
||||
variants: Variant[]
|
||||
onPreview: (variant: Variant) => void
|
||||
onEdit: (variant: Variant) => void
|
||||
}
|
||||
|
||||
function TableSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-10" /></TableCell>
|
||||
<TableCell><Skeleton className="h-5 w-16 rounded-full" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
||||
<TableCell><Skeleton className="size-7 rounded-lg" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function VariantActionsMenu({
|
||||
variant,
|
||||
onEdit,
|
||||
}: {
|
||||
variant: Variant
|
||||
onEdit: (variant: Variant) => void
|
||||
}) {
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isToggling, setIsToggling] = useState(false)
|
||||
const deleteVariant = useMutation(api.products.deleteVariant)
|
||||
const updateVariant = useMutation(api.products.updateVariant)
|
||||
|
||||
async function handleToggleActive() {
|
||||
setIsToggling(true)
|
||||
try {
|
||||
await updateVariant({ id: variant._id, isActive: !variant.isActive })
|
||||
toast.success(`"${variant.name}" ${variant.isActive ? "deactivated" : "activated"}`)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to update variant")
|
||||
} finally {
|
||||
setIsToggling(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteVariant({ id: variant._id })
|
||||
toast.success(`"${variant.name}" deleted`)
|
||||
setDeleteOpen(false)
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message ?? "Failed to delete variant")
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Actions for ${variant.name}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon icon={MoreVerticalIcon} strokeWidth={2} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(variant)}>
|
||||
<HugeiconsIcon icon={PencilEdit01Icon} strokeWidth={2} />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleToggleActive}
|
||||
disabled={isToggling}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={variant.isActive ? Cancel01Icon : CheckmarkCircle01Icon}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{variant.isActive ? "Deactivate" : "Activate"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete01Icon} strokeWidth={2} />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete “{variant.name}”?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{variant.isActive
|
||||
? "This variant will be permanently deleted. If it appears in existing orders, it will be deactivated instead."
|
||||
: "This variant will be permanently deleted."}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting…" : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function VariantsTable({ variants, onPreview, onEdit }: VariantsTableProps) {
|
||||
return (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead scope="col">Name</TableHead>
|
||||
<TableHead scope="col">SKU</TableHead>
|
||||
<TableHead scope="col">Price</TableHead>
|
||||
<TableHead scope="col">Compare at</TableHead>
|
||||
<TableHead scope="col">Stock</TableHead>
|
||||
<TableHead scope="col">Status</TableHead>
|
||||
<TableHead scope="col">Weight</TableHead>
|
||||
<TableHead scope="col" className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{variants.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={8}
|
||||
className="py-16 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
No variants yet. Create the first variant for this product.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
variants.map((variant) => (
|
||||
<TableRow key={variant._id}>
|
||||
<TableCell>
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium hover:underline text-left"
|
||||
onClick={() => onPreview(variant)}
|
||||
>
|
||||
{variant.name}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{variant.sku}
|
||||
</TableCell>
|
||||
<TableCell>{formatPrice(variant.price)}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{variant.compareAtPrice ? formatPrice(variant.compareAtPrice) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{variant.stockQuantity}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={variant.isActive ? "default" : "secondary"}>
|
||||
{variant.isActive ? "Active" : "Inactive"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{variant.weight} {variant.weightUnit}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<VariantActionsMenu variant={variant} onEdit={onEdit} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { TableSkeleton as VariantsTableSkeleton }
|
||||
19
apps/admin/src/hooks/use-mobile.ts
Normal file
19
apps/admin/src/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
26
apps/admin/src/hooks/useAdminAuth.ts
Normal file
26
apps/admin/src/hooks/useAdminAuth.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useConvexAuth, useQuery } from "convex/react";
|
||||
import { api } from "../../../../convex/_generated/api";
|
||||
|
||||
type AdminAuthState =
|
||||
| { status: "loading" }
|
||||
| { status: "authorized"; role: "admin" | "super_admin" }
|
||||
| { status: "denied"; reason: "not_authenticated" | "not_admin" | "no_user_record" };
|
||||
|
||||
export function useAdminAuth(): AdminAuthState {
|
||||
const { isLoading, isAuthenticated } = useConvexAuth();
|
||||
const user = useQuery(
|
||||
api.users.current,
|
||||
isAuthenticated ? {} : "skip",
|
||||
);
|
||||
|
||||
if (isLoading) return { status: "loading" };
|
||||
if (!isAuthenticated) return { status: "denied", reason: "not_authenticated" };
|
||||
if (user === undefined) return { status: "loading" };
|
||||
if (user === null) return { status: "denied", reason: "no_user_record" };
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
return { status: "denied", reason: "not_admin" };
|
||||
}
|
||||
return { status: "authorized", role: user.role };
|
||||
}
|
||||
31
apps/admin/src/hooks/useStoreUserEffect.ts
Normal file
31
apps/admin/src/hooks/useStoreUserEffect.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
import { useConvexAuth, useMutation } from "convex/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../../../../convex/_generated/api";
|
||||
import { Id } from "../../../../convex/_generated/dataModel";
|
||||
|
||||
export function useStoreUserEffect() {
|
||||
const { isLoading, isAuthenticated } = useConvexAuth();
|
||||
const { user } = useUser();
|
||||
const [userId, setUserId] = useState<Id<"users"> | null>(null);
|
||||
const storeUser = useMutation(api.users.store);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
let cancelled = false;
|
||||
storeUser().then((id) => {
|
||||
if (!cancelled) setUserId(id);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
setUserId(null);
|
||||
};
|
||||
}, [isAuthenticated, storeUser, user?.id]);
|
||||
|
||||
return {
|
||||
isLoading: isLoading || (isAuthenticated && userId === null),
|
||||
isAuthenticated: isAuthenticated && userId !== null,
|
||||
};
|
||||
}
|
||||
97
apps/admin/src/lib/constants/app.constants.ts
Normal file
97
apps/admin/src/lib/constants/app.constants.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
DashboardSquare02Icon,
|
||||
ShoppingCart01Icon,
|
||||
PackageIcon,
|
||||
UserMultipleIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
|
||||
export const ROUTE_LABELS: Record<string, string> = {
|
||||
orders: "Orders",
|
||||
products: "Products",
|
||||
categories: "Categories",
|
||||
images: "Images",
|
||||
variants: "Variants",
|
||||
customers: "Customers",
|
||||
reviews: "Reviews",
|
||||
messages: "Messages",
|
||||
newsletter: "Newsletter",
|
||||
users: "Users",
|
||||
settings: "Settings",
|
||||
returns: "Returns",
|
||||
};
|
||||
|
||||
export const NAV_LINKS = {
|
||||
overview: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/",
|
||||
icon: DashboardSquare02Icon,
|
||||
},
|
||||
],
|
||||
navMain: [
|
||||
{
|
||||
title: "Sales",
|
||||
url: "/orders",
|
||||
icon: ShoppingCart01Icon,
|
||||
items: [
|
||||
{
|
||||
title: "Orders",
|
||||
url: "/orders",
|
||||
},
|
||||
{
|
||||
title: "Returns",
|
||||
url: "/returns",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Products",
|
||||
url: "/products",
|
||||
icon: PackageIcon,
|
||||
items: [
|
||||
{
|
||||
title: "Categories",
|
||||
url: "/products/categories",
|
||||
},
|
||||
{
|
||||
title: "Products",
|
||||
url: "/products",
|
||||
},
|
||||
{
|
||||
title: "Images",
|
||||
url: "/images",
|
||||
},
|
||||
{
|
||||
title: "Variants",
|
||||
url: "/variant",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Customers",
|
||||
url: "/customers",
|
||||
icon: UserMultipleIcon,
|
||||
items: [
|
||||
{
|
||||
title: "Reviews",
|
||||
url: "/customers/reviews",
|
||||
},
|
||||
{
|
||||
title: "Messages",
|
||||
url: "/customers/messages",
|
||||
},
|
||||
{
|
||||
title: "Newsletter",
|
||||
url: "/customers/newsletter",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
title: "Users",
|
||||
url: "/users",
|
||||
icon: UserMultipleIcon,
|
||||
},
|
||||
],
|
||||
};
|
||||
6
apps/admin/src/lib/utils.ts
Normal file
6
apps/admin/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user