feat(admin): implement product management — list, create, edit, archive (Plan 03)
Covers checklist items 3.1–3.4, 3.10–3.11 (product list, create, edit, archive/restore, SEO fields, admin search). Backend (convex/products.ts): - Extended create/update with shortDescription, brand, attributes, seoTitle, seoDescription, canonicalSlug - Both mutations now set createdAt/updatedAt timestamps - Added getByIdForAdmin (admin-only, returns full product with relations) UI — new pages: - products/page.tsx: table with debounced search, column visibility dropdown, client-side sort, 10-row skeleton, load-more pagination, row preview dialog, per-row actions menu - products/new/page.tsx: create product page - products/[id]/edit/page.tsx: pre-populated edit page with archive button UI — new components: - ProductForm: shared form (create + edit); zod + react-hook-form, auto-slug, collapsible Attributes + SEO sections, submit spinner - ProductPreviewDialog: read-only full-product dialog - ProductActionsMenu: kebab menu (Edit link + Archive AlertDialog) ShadCN components installed: table, badge, alert-dialog, dialog, scroll-area, form, select, label, checkbox, textarea Also: - Updated CLAUDE.md: form submit buttons must use inline SVG spinner with data-icon="inline-start"; link-styled buttons use buttonVariants on <Link> (Button render prop not in TS types) - Updated docs: checklist and plan marked complete Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
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";
|
||||
```
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "radix-nova",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
|
||||
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 | `[ ]` BE+UI | Need Convex file storage upload flow → `products.addImage`. Currently `addImage` takes a URL; need `generateUploadUrl` + upload action. |
|
||||
| 3.6 | Image gallery management | `[~]` UI | Backend: `addImage`, `deleteImage`, `reorderImages`. Drag-and-drop reorder UI. |
|
||||
| 3.7 | Variant management | `[~]` UI | Backend: `addVariant`, `updateVariant`, `deleteVariant`. Inline table or modal for CRUD. |
|
||||
| 3.8 | Stock quantity editing | `[~]` UI | Backend: `updateVariant` accepts `stockQuantity`. Inline edit in variant table. |
|
||||
| 3.9 | Price and compare-at-price editing | `[~]` UI | Backend: `updateVariant` accepts `price`, `compareAtPrice`. |
|
||||
| 3.10 | Product SEO fields (title, description) | `[x]` UI | `seoTitle`, `seoDescription`, `canonicalSlug` in collapsible Advanced/SEO section. |
|
||||
| 3.11 | Product search within admin | `[x]` UI | Debounced search bar on list page; switches between `products.list` and `products.search`. |
|
||||
| 3.12 | Bulk status change (draft → active, etc.) | `[ ]` BE+UI | New mutation: `products.bulkUpdateStatus`. Multi-select in table. |
|
||||
|
||||
---
|
||||
|
||||
### 4. Category Management
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 4.1 | Category list / tree view | `[~]` UI | Backend: `categories.list` (supports `parentId`). Show hierarchical tree. |
|
||||
| 4.2 | Create category | `[~]` UI | Backend: `categories.create`. Form: name, slug, parent, top-category slug, SEO. |
|
||||
| 4.3 | Edit category | `[~]` UI | Backend: `categories.update`. |
|
||||
| 4.4 | Category image upload | `[ ]` BE+UI | Schema has `imageUrl`. Need file upload flow. |
|
||||
|
||||
---
|
||||
|
||||
### 5. Order Processing
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 5.1 | Order list page | `[~]` UI | Backend: `orders.listAll` (paginated, filterable by status/paymentStatus). |
|
||||
| 5.2 | Order detail page | `[~]` UI | Backend: `orders.getById` (returns items, addresses, payment info). |
|
||||
| 5.3 | Update order status | `[~]` UI | Backend: `orders.updateStatus`. Dropdown or status stepper. |
|
||||
| 5.4 | Cancel order (admin-initiated) | `[ ]` BE+UI | New mutation: `orders.adminCancel` — cancel regardless of customer rules, update Stripe if paid, restore stock. |
|
||||
| 5.5 | Create shipping label (Shippo) | `[ ]` BE+UI+3P | New action: `shippo.createLabel` — calls Shippo Transactions API. Store `trackingNumber`, `trackingUrl`, `labelUrl` on order. |
|
||||
| 5.6 | Print shipping label | `[ ]` UI+3P | Fetch label PDF URL from Shippo, open in new tab / trigger print dialog. |
|
||||
| 5.7 | Track shipment status | `[ ]` BE+3P | New: Shippo tracking webhook → update order `status` and `trackingUrl`. Or poll Shippo Tracking Status API. |
|
||||
| 5.8 | Refund order (full) | `[ ]` BE+UI+3P | New action: `stripe.refundPayment` — calls Stripe Refunds API. Update `paymentStatus` to `"refunded"`, `status` to `"refunded"`. |
|
||||
| 5.9 | Partial refund | `[ ]` BE+UI+3P | Same Stripe Refunds API with `amount` parameter. |
|
||||
| 5.10 | Return processing | `[ ]` BE+UI | New: `returns` table or status sub-flow. Accept return request → inspect → refund or reject. |
|
||||
| 5.11 | Send order update email | `[ ]` BE+3P | New: email service integration (Resend or SendGrid). Triggered on status changes: confirmed, shipped (with tracking), delivered, cancelled, refunded. |
|
||||
| 5.12 | Send order update SMS | `[ ]` BE+3P | New: SMS integration (Twilio or similar). Triggered on key status changes: shipped, delivered. |
|
||||
| 5.13 | Order notes (internal) | `[~]` UI | Schema has `notes` field. Admin can add/edit internal notes. |
|
||||
| 5.14 | Order search / filters | `[ ]` BE+UI | Search by order number, customer email, date range. May need new indexes. |
|
||||
| 5.15 | Batch label creation | `[ ]` BE+UI+3P | Select multiple orders → create labels via Shippo Batches API. |
|
||||
|
||||
---
|
||||
|
||||
### 6. Customer Management (MVP-lite)
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 6.1 | Customer list page | `[~]` UI | Backend: `users.listCustomers` (paginated). |
|
||||
| 6.2 | Customer detail page | `[ ]` BE+UI | New query: `users.getCustomerDetail` — user + orders + addresses. |
|
||||
| 6.3 | View customer orders | `[ ]` UI | Link from customer detail to filtered order list. |
|
||||
|
||||
---
|
||||
|
||||
## Post-MVP
|
||||
|
||||
### 7. Dashboard & Analytics
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 7.1 | Dashboard home — key metrics | `[ ]` BE+UI | New queries: total revenue, order count, new customers (time-windowed). `DashboardStats` type already exists in `@repo/types`. |
|
||||
| 7.2 | Revenue chart (daily/weekly/monthly) | `[ ]` BE+UI | New query: aggregated revenue by period. Chart library (Recharts or similar). |
|
||||
| 7.3 | Orders chart | `[ ]` BE+UI | Order volume over time. |
|
||||
| 7.4 | Top-selling products | `[ ]` BE+UI | New query: aggregate `orderItems` by product, sort by quantity. |
|
||||
| 7.5 | Low stock alerts | `[ ]` BE+UI | New query: variants where `stockQuantity` < threshold. Dashboard widget + notification badge. |
|
||||
| 7.6 | Recent orders feed | `[~]` UI | Backend: `orders.listAll` with `limit`. Real-time feed on dashboard. |
|
||||
| 7.7 | Conversion funnel | `[ ]` BE+UI | Track: visits → cart adds → checkouts → completed orders. Requires analytics events. |
|
||||
|
||||
---
|
||||
|
||||
### 8. Review Management
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 8.1 | Review list page (all reviews) | `[~]` UI | Backend: `reviews.listForAdmin` (filterable by approval status, product). |
|
||||
| 8.2 | Approve review | `[~]` UI | Backend: `reviews.approve`. |
|
||||
| 8.3 | Delete review | `[~]` UI | Backend: `reviews.deleteReview`. |
|
||||
| 8.4 | Review detail / preview | `[ ]` UI | Show full review content, images, linked product. |
|
||||
| 8.5 | Bulk approve/delete | `[ ]` BE+UI | New mutations for batch operations. |
|
||||
|
||||
---
|
||||
|
||||
### 9. Customer Communication
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 9.1 | Contact form messages inbox | `[ ]` BE+UI | New `messages` table. Storefront contact form → Convex. Admin reads/replies. |
|
||||
| 9.2 | Reply to customer message | `[ ]` BE+UI+3P | Send reply via email (Resend/SendGrid). Store thread in Convex. |
|
||||
| 9.3 | Message status (unread/read/resolved) | `[ ]` BE+UI | Status field on messages table. |
|
||||
| 9.4 | Email templates | `[ ]` BE+3P | Transactional email templates for order updates, review responses, etc. |
|
||||
|
||||
---
|
||||
|
||||
### 10. Newsletter & Marketing
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 10.1 | Newsletter subscriber list | `[ ]` BE+UI | New `subscribers` table. Storefront signup → Convex. Admin views list. |
|
||||
| 10.2 | Export subscribers (CSV) | `[ ]` UI | Client-side CSV generation from subscriber list. |
|
||||
| 10.3 | Compose & send newsletter | `[ ]` BE+3P | Integration with email provider (Resend/Mailchimp). Template editor. |
|
||||
| 10.4 | Unsubscribe handling | `[ ]` BE | Unsubscribe link in emails → Convex mutation. |
|
||||
|
||||
---
|
||||
|
||||
### 11. Promotions & Discounts
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 11.1 | Coupon/discount code management | `[ ]` BE+UI+3P | New `coupons` table or Stripe Coupons API (already available via MCP). CRUD UI for codes, percentage/fixed amount, expiry, usage limits. |
|
||||
| 11.2 | Sale tag management | `[~]` UI | Products already have `tags[]`. Admin can add/remove "sale" tag. Backend: `products.update`. |
|
||||
| 11.3 | Compare-at-price (was/now pricing) | `[~]` UI | Schema has `compareAtPrice` on variants. Editable in variant management. |
|
||||
|
||||
---
|
||||
|
||||
### 12. Admin User Management
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 12.1 | Admin/staff list | `[ ]` BE+UI | New query: `users.listAdmins`. |
|
||||
| 12.2 | Promote user to admin | `[ ]` BE+UI | New mutation: `users.setRole` (super_admin only). |
|
||||
| 12.3 | Demote admin to customer | `[ ]` BE+UI | Same `users.setRole` mutation. |
|
||||
| 12.4 | Activity / audit log | `[ ]` BE+UI | New `auditLogs` table. Log admin actions with userId, action, target, timestamp. |
|
||||
|
||||
---
|
||||
|
||||
### 13. Settings & Configuration
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 13.1 | Store settings (name, logo, contact info) | `[ ]` BE+UI | New `storeSettings` table (singleton). |
|
||||
| 13.2 | Shipping configuration | `[ ]` BE+UI | Default parcel dimensions, weight limits, carrier preferences. Currently hardcoded in `model/shippo.ts`. |
|
||||
| 13.3 | Tax configuration | `[ ]` BE+UI | Tax rates by region. Currently `tax` is passed manually on order creation. |
|
||||
| 13.4 | Email notification preferences | `[ ]` BE+UI | Which status changes trigger emails/SMS. |
|
||||
| 13.5 | Payment settings (Stripe config) | `[ ]` UI | Display Stripe connection status, webhook health. |
|
||||
|
||||
---
|
||||
|
||||
### 14. Data & Export
|
||||
|
||||
| # | Feature | Status | Notes |
|
||||
|---|---------|--------|-------|
|
||||
| 14.1 | Export orders (CSV) | `[ ]` UI | Client-side CSV from `orders.listAll`. |
|
||||
| 14.2 | Export products (CSV) | `[ ]` UI | Client-side CSV from `products.listAll`. |
|
||||
| 14.3 | Export customers (CSV) | `[ ]` UI | Client-side CSV from `users.listCustomers`. |
|
||||
| 14.4 | Import products (CSV) | `[ ]` BE+UI | Parse CSV → batch `products.create` calls. |
|
||||
|
||||
---
|
||||
|
||||
## MVP Scope Summary
|
||||
|
||||
| Section | Features | New Backend Work | Third-Party |
|
||||
|---------|----------|------------------|-------------|
|
||||
| 1. Auth & Authorization | 6 | None | — |
|
||||
| 2. Navigation & Layout | 4 | None | — |
|
||||
| 3. Product Management | 12 | File upload, bulk status | — |
|
||||
| 4. Category Management | 4 | File upload | — |
|
||||
| 5. Order Processing | 15 | Cancel, refund, label, tracking, return, email, SMS, search, batch | Shippo, Stripe, Resend/SendGrid, Twilio |
|
||||
| 6. Customer Management | 3 | Customer detail query | — |
|
||||
| **Total MVP** | **44** | | |
|
||||
|
||||
---
|
||||
|
||||
## Third-Party Integration Summary
|
||||
|
||||
| Service | Purpose | MVP? | Existing? |
|
||||
|---------|---------|------|-----------|
|
||||
| **Clerk** | Authentication | Yes | Yes — sign-in, JWT, webhooks |
|
||||
| **Convex** | Backend, real-time DB | Yes | Yes — full schema + functions |
|
||||
| **Stripe** | Payments, refunds | Yes | Partial — checkout exists, refunds needed |
|
||||
| **Shippo** | Shipping labels, tracking | Yes | Partial — rates/validation exist, labels/tracking needed |
|
||||
| **Resend** or **SendGrid** | Transactional email | Yes | No — not integrated |
|
||||
| **Twilio** or **SNS** | SMS notifications | Yes | No — not integrated |
|
||||
| **Recharts** or **Chart.js** | Dashboard charts | Post-MVP | No |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Implementation Order (MVP)
|
||||
|
||||
```
|
||||
Phase 1 ─ Auth & Layout (1-2 days)
|
||||
├─ 1.1–1.6 Authentication & authorization
|
||||
└─ 2.1–2.4 Navigation & layout shell
|
||||
|
||||
Phase 2 ─ Product Management (3-4 days)
|
||||
├─ 3.1–3.4 Product list, create, edit, archive
|
||||
├─ 3.5–3.6 Image upload & gallery
|
||||
├─ 3.7–3.9 Variant CRUD, stock, pricing
|
||||
├─ 3.10–3.11 SEO fields, search
|
||||
└─ 4.1–4.4 Category management
|
||||
|
||||
Phase 3 ─ Order Processing — Core (2-3 days)
|
||||
├─ 5.1–5.3 Order list, detail, status update
|
||||
├─ 5.4 Admin cancel
|
||||
├─ 5.13–5.14 Order notes, search
|
||||
|
||||
Phase 4 ─ Shipping & Labels (2-3 days)
|
||||
├─ 5.5–5.6 Create & print labels (Shippo)
|
||||
├─ 5.7 Track shipments
|
||||
└─ 5.15 Batch label creation
|
||||
|
||||
Phase 5 ─ Refunds & Returns (1-2 days)
|
||||
├─ 5.8–5.9 Full & partial refund (Stripe)
|
||||
└─ 5.10 Return processing
|
||||
|
||||
Phase 6 ─ Notifications (1-2 days)
|
||||
├─ 5.11 Order update emails
|
||||
└─ 5.12 Order update SMS
|
||||
|
||||
Phase 7 ─ Customer Management (1 day)
|
||||
└─ 6.1–6.3 Customer list, detail, orders
|
||||
```
|
||||
|
||||
Total estimated MVP effort: **11–17 days** for a senior engineer.
|
||||
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`
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@clerk/nextjs": "^6.38.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@hugeicons/core-free-icons": "^3.3.0",
|
||||
"@hugeicons/react": "^1.1.5",
|
||||
"@repo/convex": "*",
|
||||
@@ -21,7 +22,9 @@
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.400.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"tailwind-merge": "^2.6.1"
|
||||
"react-hook-form": "^7.71.2",
|
||||
"tailwind-merge": "^2.6.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,471 @@
|
||||
import StillBuildingPlaceholder from "../../../components/shared/still_building_placeholder";
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useMemo } from "react"
|
||||
import { usePaginatedQuery, useQuery } from "convex/react"
|
||||
import { api } from "../../../../../../convex/_generated/api"
|
||||
import type { Id } from "../../../../../../convex/_generated/dataModel"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import {
|
||||
Search01Icon,
|
||||
Cancel01Icon,
|
||||
ArrowUpDownIcon,
|
||||
SortByUp01Icon,
|
||||
SortByDown01Icon,
|
||||
AddCircleIcon,
|
||||
} from "@hugeicons/core-free-icons"
|
||||
import {
|
||||
ProductPreviewDialog,
|
||||
type PreviewProduct,
|
||||
} from "@/components/products/ProductPreviewDialog"
|
||||
import { ProductActionsMenu } from "@/components/products/ProductActionsMenu"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type SortField = "name" | "brand" | "childCategory"
|
||||
type SortOrder = "asc" | "desc"
|
||||
type ColumnKey = "brand" | "childCategory" | "status" | "slug" | "tags" | "updated"
|
||||
|
||||
const OPTIONAL_COLUMNS: { key: ColumnKey; label: string }[] = [
|
||||
{ key: "brand", label: "Brand" },
|
||||
{ key: "childCategory", label: "Category" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "slug", label: "Slug" },
|
||||
{ key: "tags", label: "Tags" },
|
||||
{ key: "updated", label: "Updated" },
|
||||
]
|
||||
|
||||
const DEFAULT_VISIBLE: Record<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() {
|
||||
return <StillBuildingPlaceholder />;
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,10 +36,8 @@ export function DynamicBreadcrumb() {
|
||||
<BreadcrumbList>
|
||||
{/* Home icon link */}
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href="/">
|
||||
<BreadcrumbLink render={<Link href="/" />}>
|
||||
<HugeiconsIcon icon={Home01Icon} size={16} />
|
||||
</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
|
||||
@@ -55,8 +53,8 @@ export function DynamicBreadcrumb() {
|
||||
{isLast ? (
|
||||
<BreadcrumbPage>{label}</BreadcrumbPage>
|
||||
) : (
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={href}>{label}</Link>
|
||||
<BreadcrumbLink render={<Link href={href} />}>
|
||||
{label}
|
||||
</BreadcrumbLink>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" tooltip="The Pet Loft Admin" asChild={false}>
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
|
||||
@@ -16,26 +17,64 @@ import {
|
||||
} from "@/components/ui/sidebar";
|
||||
import { CollapsibleTrigger, CollapsibleContent, Collapsible } from "@/components/ui/collapsible";
|
||||
|
||||
type NavItem = {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: IconSvgElement;
|
||||
items?: { title: string; url: string }[];
|
||||
};
|
||||
|
||||
function NavMainItem({ item, pathname }: { item: NavItem; pathname: string }) {
|
||||
const isGroupActive = pathname.startsWith(item.url);
|
||||
const [open, setOpen] = useState(isGroupActive);
|
||||
|
||||
// Auto-open when navigating into this group's routes
|
||||
useEffect(() => {
|
||||
if (isGroupActive) setOpen(true);
|
||||
}, [isGroupActive]);
|
||||
|
||||
return (
|
||||
<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: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: IconSvgElement;
|
||||
}[];
|
||||
overview: Omit<NavItem, "items">[];
|
||||
isOverview?: boolean;
|
||||
navMain: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: IconSvgElement;
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
}[];
|
||||
}[];
|
||||
navMain: NavItem[];
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
@@ -46,11 +85,13 @@ export function NavMain({
|
||||
<SidebarMenu>
|
||||
{overview.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild tooltip={item.title} isActive={pathname === item.url}>
|
||||
<Link href={item.url}>
|
||||
<SidebarMenuButton
|
||||
render={<Link href={item.url} />}
|
||||
tooltip={item.title}
|
||||
isActive={pathname === item.url}
|
||||
>
|
||||
<HugeiconsIcon icon={item.icon} size={16} />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
@@ -63,40 +104,9 @@ export function NavMain({
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Application</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{navMain.map((item) => {
|
||||
const isGroupActive = pathname.startsWith(item.url);
|
||||
return (
|
||||
<Collapsible
|
||||
key={item.title}
|
||||
asChild
|
||||
defaultOpen={isGroupActive}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<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-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton asChild isActive={pathname === subItem.url}>
|
||||
<Link href={subItem.url}>
|
||||
<span>{subItem.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
{navMain.map((item) => (
|
||||
<NavMainItem key={item.title} item={item} pathname={pathname} />
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
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,
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -9,7 +9,7 @@ function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
@@ -17,7 +17,7 @@ function Avatar({
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -25,14 +25,14 @@ function Avatar({
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -41,7 +41,7 @@ function AvatarImage({
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
@@ -59,7 +59,7 @@ function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
<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 ring-2 ring-background select-none",
|
||||
"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",
|
||||
@@ -103,7 +103,7 @@ export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarBadge,
|
||||
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 }
|
||||
@@ -1,11 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
import { Slot } from "radix-ui"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { ArrowRight01Icon, MoreHorizontalCircle01Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
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">) {
|
||||
@@ -13,7 +22,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5",
|
||||
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -25,28 +34,30 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
className={cn("inline-flex items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
render,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...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">) {
|
||||
@@ -75,7 +86,9 @@ function BreadcrumbSeparator({
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
{children ?? (
|
||||
<HugeiconsIcon icon={ArrowRight01Icon} strokeWidth={2} />
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -89,10 +102,13 @@ function BreadcrumbEllipsis({
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
className={cn(
|
||||
"flex size-5 items-center justify-center [&>svg]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<HugeiconsIcon icon={MoreHorizontalCircle01Icon} strokeWidth={2} />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
import * as React from "react"
|
||||
"use client"
|
||||
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -42,19 +46,11 @@ function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<Comp
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
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 }
|
||||
@@ -1,32 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
||||
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -1,61 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { HugeiconsIcon } from "@hugeicons/react"
|
||||
import { ArrowRight01Icon, Tick02Icon } from "@hugeicons/core-free-icons"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
<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(
|
||||
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,17 +79,17 @@ function DropdownMenuItem({
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
"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}
|
||||
@@ -82,37 +97,94 @@ function DropdownMenuItem({
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent 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",
|
||||
"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 left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
<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}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
@@ -122,53 +194,40 @@ function DropdownMenuRadioGroup({
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent 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",
|
||||
"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 left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
<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}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
@@ -184,53 +243,7 @@ function DropdownMenuShortcut({
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
"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}
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -1,23 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,42 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
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 }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
"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}
|
||||
@@ -50,37 +43,39 @@ function SheetContent({
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
side === "bottom" &&
|
||||
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
"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 className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<XIcon className="size-4" />
|
||||
<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.Content>
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
@@ -89,7 +84,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -105,14 +100,11 @@ function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("font-semibold text-foreground", className)}
|
||||
className={cn("text-base font-medium text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -121,7 +113,7 @@ function SheetTitle({
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"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 { PanelLeftIcon } from "lucide-react"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -21,9 +21,10 @@ import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
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
|
||||
@@ -128,7 +129,6 @@ function SidebarProvider({
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
@@ -146,7 +146,6 @@ function SidebarProvider({
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -157,6 +156,7 @@ function Sidebar({
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
dir,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right"
|
||||
@@ -184,6 +184,7 @@ function Sidebar({
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
dir={dir}
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
@@ -228,11 +229,9 @@ function Sidebar({
|
||||
/>
|
||||
<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 md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
"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)]"
|
||||
@@ -244,7 +243,7 @@ function Sidebar({
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:shadow-sm"
|
||||
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>
|
||||
@@ -265,15 +264,15 @@ function SidebarTrigger({
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
size="icon-sm"
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<HugeiconsIcon icon={SidebarLeftIcon} strokeWidth={2} />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
@@ -291,7 +290,7 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex",
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
@@ -309,8 +308,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
<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",
|
||||
"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}
|
||||
@@ -374,7 +372,7 @@ function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -395,46 +393,50 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
render,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
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 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
}: 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}
|
||||
/>
|
||||
)
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-label",
|
||||
sidebar: "group-label",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
render,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
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 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
}: 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}
|
||||
/>
|
||||
)
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-group-action",
|
||||
sidebar: "group-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
@@ -456,7 +458,7 @@ function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -474,7 +476,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -496,34 +498,38 @@ const sidebarMenuButtonVariants = cva(
|
||||
)
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
render,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
isActive?: boolean
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const { isMobile, state } = useSidebar()
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
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 button
|
||||
return comp
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
@@ -534,7 +540,7 @@ function SidebarMenuButton({
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
{comp}
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
@@ -547,34 +553,32 @@ function SidebarMenuButton({
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
render,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean
|
||||
}: useRender.ComponentProps<"button"> &
|
||||
React.ComponentProps<"button"> & {
|
||||
showOnHover?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
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 peer-hover/menu-button:text-sidebar-accent-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
}) {
|
||||
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=true]/menu-button:text-sidebar-accent-foreground data-[state=open]:opacity-100 md:opacity-0",
|
||||
"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}
|
||||
/>
|
||||
)
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-action",
|
||||
sidebar: "menu-action",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
@@ -586,12 +590,7 @@ function SidebarMenuBadge({
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -607,9 +606,9 @@ function SidebarMenuSkeleton({
|
||||
showIcon?: boolean
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
const [width] = React.useState(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||
}, [])
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -643,8 +642,7 @@ function SidebarMenuSub({ className, ...props }: React.ComponentProps<"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",
|
||||
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -667,35 +665,35 @@ function SidebarMenuSubItem({
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
render,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}: useRender.ComponentProps<"a"> &
|
||||
React.ComponentProps<"a"> & {
|
||||
size?: "sm" | "md"
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
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 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 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
}) {
|
||||
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}
|
||||
/>
|
||||
)
|
||||
),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "sidebar-menu-sub-button",
|
||||
sidebar: "menu-sub-button",
|
||||
size,
|
||||
active: isActive,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
|
||||
@@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-accent", className)}
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
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 }
|
||||
@@ -1,55 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
delay = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
<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 origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 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-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
"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" />
|
||||
</TooltipPrimitive.Content>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -557,6 +557,14 @@ export const getById = internalQuery({
|
||||
},
|
||||
});
|
||||
|
||||
export const getByIdForAdmin = query({
|
||||
args: { id: v.id("products") },
|
||||
handler: async (ctx, { id }) => {
|
||||
await Users.requireAdmin(ctx);
|
||||
return getProductWithRelations(ctx, id);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* One-time migration: backfill parentCategorySlug, childCategorySlug, topCategorySlug
|
||||
* from categories. Run once after deploying Phase 1 schema.
|
||||
@@ -704,13 +712,27 @@ export const create = mutation({
|
||||
name: v.string(),
|
||||
slug: v.string(),
|
||||
description: v.optional(v.string()),
|
||||
shortDescription: v.optional(v.string()),
|
||||
status: v.union(
|
||||
v.literal("active"),
|
||||
v.literal("draft"),
|
||||
v.literal("archived"),
|
||||
),
|
||||
categoryId: v.id("categories"),
|
||||
brand: v.optional(v.string()),
|
||||
tags: v.array(v.string()),
|
||||
attributes: v.optional(
|
||||
v.object({
|
||||
petSize: v.optional(v.array(v.string())),
|
||||
ageRange: v.optional(v.array(v.string())),
|
||||
specialDiet: v.optional(v.array(v.string())),
|
||||
material: v.optional(v.string()),
|
||||
flavor: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
seoTitle: v.optional(v.string()),
|
||||
seoDescription: v.optional(v.string()),
|
||||
canonicalSlug: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await Users.requireAdmin(ctx);
|
||||
@@ -726,6 +748,8 @@ export const create = mutation({
|
||||
parentCategorySlug,
|
||||
childCategorySlug,
|
||||
...(topCategorySlug !== undefined && { topCategorySlug }),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -736,6 +760,7 @@ export const update = mutation({
|
||||
name: v.optional(v.string()),
|
||||
slug: v.optional(v.string()),
|
||||
description: v.optional(v.string()),
|
||||
shortDescription: v.optional(v.string()),
|
||||
status: v.optional(
|
||||
v.union(
|
||||
v.literal("active"),
|
||||
@@ -744,7 +769,20 @@ export const update = mutation({
|
||||
),
|
||||
),
|
||||
categoryId: v.optional(v.id("categories")),
|
||||
brand: v.optional(v.string()),
|
||||
tags: v.optional(v.array(v.string())),
|
||||
attributes: v.optional(
|
||||
v.object({
|
||||
petSize: v.optional(v.array(v.string())),
|
||||
ageRange: v.optional(v.array(v.string())),
|
||||
specialDiet: v.optional(v.array(v.string())),
|
||||
material: v.optional(v.string()),
|
||||
flavor: v.optional(v.string()),
|
||||
}),
|
||||
),
|
||||
seoTitle: v.optional(v.string()),
|
||||
seoDescription: v.optional(v.string()),
|
||||
canonicalSlug: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, { id, ...updates }) => {
|
||||
await Users.requireAdmin(ctx);
|
||||
@@ -769,6 +807,8 @@ export const update = mutation({
|
||||
}
|
||||
}
|
||||
|
||||
fields.updatedAt = Date.now();
|
||||
|
||||
await ctx.db.patch(id, fields);
|
||||
return id;
|
||||
},
|
||||
|
||||
41
package-lock.json
generated
41
package-lock.json
generated
@@ -47,6 +47,7 @@
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@clerk/nextjs": "^6.38.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@hugeicons/core-free-icons": "^3.3.0",
|
||||
"@hugeicons/react": "^1.1.5",
|
||||
"@repo/convex": "*",
|
||||
@@ -56,7 +57,9 @@
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.400.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"tailwind-merge": "^2.6.1"
|
||||
"react-hook-form": "^7.71.2",
|
||||
"tailwind-merge": "^2.6.1",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
@@ -1769,6 +1772,18 @@
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@hookform/resolvers": {
|
||||
"version": "5.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz",
|
||||
"integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/utils": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react-hook-form": "^7.55.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hugeicons/core-free-icons": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.3.0.tgz",
|
||||
@@ -8522,6 +8537,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stripe/react-stripe-js": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.6.0.tgz",
|
||||
@@ -15605,6 +15626,23 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.71.2",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
||||
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
@@ -18161,7 +18199,6 @@
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
|
||||
Reference in New Issue
Block a user