commit 9d523f8b6aefe9b015a326b7b90786aa76c2ea96 Author: Matias Berlinger Date: Sat Mar 7 11:07:45 2026 -0300 Initial commit diff --git a/.agent/skills/building-admin-dashboard-customizations b/.agent/skills/building-admin-dashboard-customizations new file mode 120000 index 0000000..664d8e7 --- /dev/null +++ b/.agent/skills/building-admin-dashboard-customizations @@ -0,0 +1 @@ +../../.agents/skills/building-admin-dashboard-customizations \ No newline at end of file diff --git a/.agent/skills/building-storefronts b/.agent/skills/building-storefronts new file mode 120000 index 0000000..e24e226 --- /dev/null +++ b/.agent/skills/building-storefronts @@ -0,0 +1 @@ +../../.agents/skills/building-storefronts \ No newline at end of file diff --git a/.agent/skills/building-with-medusa b/.agent/skills/building-with-medusa new file mode 120000 index 0000000..22df777 --- /dev/null +++ b/.agent/skills/building-with-medusa @@ -0,0 +1 @@ +../../.agents/skills/building-with-medusa \ No newline at end of file diff --git a/.agent/skills/db-generate b/.agent/skills/db-generate new file mode 120000 index 0000000..326af82 --- /dev/null +++ b/.agent/skills/db-generate @@ -0,0 +1 @@ +../../.agents/skills/db-generate \ No newline at end of file diff --git a/.agent/skills/db-migrate b/.agent/skills/db-migrate new file mode 120000 index 0000000..6557706 --- /dev/null +++ b/.agent/skills/db-migrate @@ -0,0 +1 @@ +../../.agents/skills/db-migrate \ No newline at end of file diff --git a/.agent/skills/new-user b/.agent/skills/new-user new file mode 120000 index 0000000..f6947c6 --- /dev/null +++ b/.agent/skills/new-user @@ -0,0 +1 @@ +../../.agents/skills/new-user \ No newline at end of file diff --git a/.agent/skills/storefront-best-practices b/.agent/skills/storefront-best-practices new file mode 120000 index 0000000..c257c1b --- /dev/null +++ b/.agent/skills/storefront-best-practices @@ -0,0 +1 @@ +../../.agents/skills/storefront-best-practices \ No newline at end of file diff --git a/.agents/skills/building-admin-dashboard-customizations/SKILL.md b/.agents/skills/building-admin-dashboard-customizations/SKILL.md new file mode 100644 index 0000000..2e2d318 --- /dev/null +++ b/.agents/skills/building-admin-dashboard-customizations/SKILL.md @@ -0,0 +1,462 @@ +--- +name: building-admin-dashboard-customizations +description: Load automatically when planning, researching, or implementing Medusa Admin dashboard UI (widgets, custom pages, forms, tables, data loading, navigation). REQUIRED for all admin UI work in ALL modes (planning, implementation, exploration). Contains design patterns, component usage, and data loading patterns that MCP servers don't provide. +--- + +# Medusa Admin Dashboard Customizations + +Build custom UI extensions for the Medusa Admin dashboard using the Admin SDK and Medusa UI components. + +**Note:** "UI Routes" are custom admin pages, different from backend API routes (which use building-with-medusa skill). + +## When to Apply + +**Load this skill for ANY admin UI development task, including:** +- Creating widgets for product/order/customer pages +- Building custom admin pages +- Implementing forms and modals +- Displaying data with tables or lists +- Adding navigation between pages + +**Also load these skills when:** +- **building-with-medusa:** Building backend API routes that the admin UI calls +- **building-storefronts:** If working on storefront instead of admin dashboard + +## CRITICAL: Load Reference Files When Needed + +**The quick reference below is NOT sufficient for implementation.** You MUST load relevant reference files before writing code for that component. + +**Load these references based on what you're implementing:** + +- **Creating widgets?** → MUST load `references/data-loading.md` first +- **Building forms/modals?** → MUST load `references/forms.md` first +- **Displaying data in tables/lists?** → MUST load `references/display-patterns.md` first +- **Selecting from large datasets?** → MUST load `references/table-selection.md` first +- **Adding navigation?** → MUST load `references/navigation.md` first +- **Styling components?** → MUST load `references/typography.md` first + +**Minimum requirement:** Load at least 1-2 reference files relevant to your specific task before implementing. + +## When to Use This Skill vs MedusaDocs MCP Server + +**⚠️ CRITICAL: This skill should be consulted FIRST for planning and implementation.** + +**Use this skill for (PRIMARY SOURCE):** +- **Planning** - Understanding how to structure admin UI features +- **Component patterns** - Widgets, pages, forms, tables, modals +- **Design system** - Typography, colors, spacing, semantic classes +- **Data loading** - Critical separate query pattern, cache invalidation +- **Best practices** - Correct vs incorrect patterns (e.g., display queries on mount) +- **Critical rules** - What NOT to do (common mistakes like conditional display queries) + +**Use MedusaDocs MCP server for (SECONDARY SOURCE):** +- Specific component prop signatures after you know which component to use +- Available widget zones list +- JS SDK method details +- Configuration options reference + +**Why skills come first:** +- Skills contain critical patterns like separate display/modal queries that MCP doesn't emphasize +- Skills show correct vs incorrect patterns; MCP shows what's possible +- Planning requires understanding patterns, not just API reference + +## Critical Setup Rules + +### SDK Client Configuration + +**CRITICAL:** Always use exact configuration - different values cause errors: + +```tsx +// src/admin/lib/client.ts +import Medusa from "@medusajs/js-sdk" + +export const sdk = new Medusa({ + baseUrl: import.meta.env.VITE_BACKEND_URL || "/", + debug: import.meta.env.DEV, + auth: { + type: "session", + }, +}) +``` + +### pnpm Users ONLY + +**CRITICAL:** Install peer dependencies BEFORE writing any code: + +```bash +# Find exact version from dashboard +pnpm list @tanstack/react-query --depth=10 | grep @medusajs/dashboard +# Install that exact version +pnpm add @tanstack/react-query@[exact-version] + +# If using navigation (Link component) +pnpm list react-router-dom --depth=10 | grep @medusajs/dashboard +pnpm add react-router-dom@[exact-version] +``` + +**npm/yarn users:** DO NOT install these packages - already available. + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | Data Loading | CRITICAL | `data-` | +| 2 | Design System | CRITICAL | `design-` | +| 3 | Data Display | HIGH (includes CRITICAL price rule) | `display-` | +| 4 | Typography | HIGH | `typo-` | +| 5 | Forms & Modals | MEDIUM | `form-` | +| 6 | Selection Patterns | MEDIUM | `select-` | + +## Quick Reference + +### 1. Data Loading (CRITICAL) + +- `data-sdk-always` - **ALWAYS use Medusa JS SDK for ALL API requests** - NEVER use regular fetch() (missing auth headers causes errors) +- `data-sdk-method-choice` - Use existing SDK methods for built-in endpoints (`sdk.admin.product.list()`), use `sdk.client.fetch()` for custom routes +- `data-display-on-mount` - Display queries MUST load on mount (no enabled condition based on UI state) +- `data-separate-queries` - Separate display queries from modal/form queries +- `data-invalidate-display` - Invalidate display queries after mutations, not just modal queries +- `data-loading-states` - Always show loading states (Spinner), not empty states +- `data-pnpm-install-first` - pnpm users MUST install @tanstack/react-query BEFORE coding + +### 2. Design System (CRITICAL) + +- `design-semantic-colors` - Always use semantic color classes (bg-ui-bg-base, text-ui-fg-subtle), never hardcoded +- `design-spacing` - Use px-6 py-4 for section padding, gap-2 for lists, gap-3 for items +- `design-button-size` - Always use size="small" for buttons in widgets and tables +- `design-medusa-components` - Always use Medusa UI components (Container, Button, Text), not raw HTML + +### 3. Data Display (HIGH) + +- `display-price-format` - **CRITICAL**: Prices from Medusa are stored as-is ($49.99 = 49.99, NOT in cents). Display them directly - NEVER divide by 100 + +### 4. Typography (HIGH) + +- `typo-text-component` - Always use Text component from @medusajs/ui, never plain span/p tags +- `typo-labels` - Use `` for labels/headings +- `typo-descriptions` - Use `` for descriptions +- `typo-no-heading-widgets` - Never use Heading for small sections in widgets (use Text instead) + +### 5. Forms & Modals (MEDIUM) + +- `form-focusmodal-create` - Use FocusModal for creating new entities +- `form-drawer-edit` - Use Drawer for editing existing entities +- `form-disable-pending` - Always disable actions during mutations (disabled={mutation.isPending}) +- `form-show-loading` - Show loading state on submit button (isLoading={mutation.isPending}) + +### 6. Selection Patterns (MEDIUM) + +- `select-small-datasets` - Use Select component for 2-10 options (statuses, types, etc.) +- `select-large-datasets` - Use DataTable with FocusModal for large datasets (products, categories, etc.) +- `select-search-config` - Must pass search configuration to useDataTable to avoid "search not enabled" error + +## Critical Data Loading Pattern + +**ALWAYS follow this pattern - never load display data conditionally:** + +```tsx +// ✅ CORRECT - Separate queries with proper responsibilities +const RelatedProductsWidget = ({ data: product }) => { + const [modalOpen, setModalOpen] = useState(false) + + // Display query - loads on mount + const { data: displayProducts } = useQuery({ + queryFn: () => fetchSelectedProducts(selectedIds), + queryKey: ["related-products-display", product.id], + // No 'enabled' condition - loads immediately + }) + + // Modal query - loads when needed + const { data: modalProducts } = useQuery({ + queryFn: () => sdk.admin.product.list({ limit: 10, offset: 0 }), + queryKey: ["products-selection"], + enabled: modalOpen, // OK for modal-only data + }) + + // Mutation with proper invalidation + const updateProduct = useMutation({ + mutationFn: updateFunction, + onSuccess: () => { + // Invalidate display data query to refresh UI + queryClient.invalidateQueries({ queryKey: ["related-products-display", product.id] }) + // Also invalidate the entity query + queryClient.invalidateQueries({ queryKey: ["product", product.id] }) + // Note: No need to invalidate modal selection query + }, + }) + + return ( + + {/* Display uses displayProducts */} + {displayProducts?.map(p =>
{p.title}
)} + + + {/* Modal uses modalProducts */} + +
+ ) +} + +// ❌ WRONG - Single query with conditional loading +const BrokenWidget = ({ data: product }) => { + const [modalOpen, setModalOpen] = useState(false) + + const { data } = useQuery({ + queryFn: () => sdk.admin.product.list(), + enabled: modalOpen, // ❌ Display breaks on page refresh! + }) + + // Trying to display from modal query + const displayItems = data?.filter(item => ids.includes(item.id)) // No data until modal opens + + return
{displayItems?.map(...)}
// Empty on mount! +} +``` + +**Why this matters:** +- On page refresh, modal is closed, so conditional query doesn't run +- User sees empty state instead of their data +- Display depends on modal interaction (broken UX) + +## Common Mistakes Checklist + +Before implementing, verify you're NOT doing these: + +**Data Loading:** +- [ ] Using regular fetch() instead of Medusa JS SDK (causes missing auth header errors) +- [ ] Not using existing SDK methods for built-in endpoints (e.g., using sdk.client.fetch("/admin/products") instead of sdk.admin.product.list()) +- [ ] Loading display data conditionally based on modal/UI state +- [ ] Using a single query for both display and modal +- [ ] Forgetting to invalidate display queries after mutations +- [ ] Not handling loading states (showing empty instead of spinner) +- [ ] pnpm users: Not installing @tanstack/react-query before coding + +**Design System:** +- [ ] Using hardcoded colors instead of semantic classes +- [ ] Forgetting size="small" on buttons in widgets +- [ ] Not using px-6 py-4 for section padding +- [ ] Using raw HTML elements instead of Medusa UI components + +**Data Display:** +- [ ] **CRITICAL**: Dividing prices by 100 when displaying (prices are stored as-is: $49.99 = 49.99, NOT in cents) + +**Typography:** +- [ ] Using plain span/p tags instead of Text component +- [ ] Not using weight="plus" for labels +- [ ] Not using text-ui-fg-subtle for descriptions +- [ ] Using Heading in small widget sections + +**Forms:** +- [ ] Using Drawer for creating (should use FocusModal) +- [ ] Using FocusModal for editing (should use Drawer) +- [ ] Not disabling buttons during mutations +- [ ] Not showing loading state on submit + +**Selection:** +- [ ] Using DataTable for <10 items (overkill) +- [ ] Using Select for >10 items (poor UX) +- [ ] Not configuring search in useDataTable (causes error) + +## Reference Files Available + +Load these for detailed patterns: + +``` +references/data-loading.md - useQuery/useMutation patterns, cache invalidation +references/forms.md - FocusModal/Drawer patterns, validation +references/table-selection.md - Complete DataTable selection pattern +references/display-patterns.md - Lists, tables, cards for entities +references/typography.md - Text component patterns +references/navigation.md - Link, useNavigate, useParams patterns +``` + +Each reference contains: +- Step-by-step implementation guides +- Correct vs incorrect code examples +- Common mistakes and solutions +- Complete working examples + +## Integration with Backend + +**⚠️ CRITICAL: ALWAYS use the Medusa JS SDK for ALL API requests - NEVER use regular fetch()** + +Admin UI connects to backend API routes using the SDK: + +```tsx +import { sdk } from "[LOCATE SDK INSTANCE IN PROJECT]" + +// ✅ CORRECT - Built-in endpoint: Use existing SDK method +const { data: product } = useQuery({ + queryKey: ["product", productId], + queryFn: () => sdk.admin.product.retrieve(productId), +}) + +// ✅ CORRECT - Custom endpoint: Use sdk.client.fetch() +const { data: reviews } = useQuery({ + queryKey: ["reviews", product.id], + queryFn: () => sdk.client.fetch(`/admin/products/${product.id}/reviews`), +}) + +// ❌ WRONG - Using regular fetch +const { data } = useQuery({ + queryKey: ["reviews", product.id], + queryFn: () => fetch(`http://localhost:9000/admin/products/${product.id}/reviews`), + // ❌ Error: Missing Authorization header! +}) + +// Mutation to custom backend route +const createReview = useMutation({ + mutationFn: (data) => sdk.client.fetch("/admin/reviews", { + method: "POST", + body: data + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["reviews", product.id] }) + toast.success("Review created") + }, +}) +``` + +**Why the SDK is required:** +- Admin routes need `Authorization` and session cookie headers +- Store routes need `x-publishable-api-key` header +- SDK handles all required headers automatically +- Regular fetch() without headers → authentication/authorization errors +- Using existing SDK methods provides better type safety + +**When to use what:** +- **Built-in endpoints**: Use existing SDK methods (`sdk.admin.product.list()`, `sdk.store.product.list()`) +- **Custom endpoints**: Use `sdk.client.fetch()` for your custom API routes + +**For implementing backend API routes**, load the `building-with-medusa` skill. + +## Widget vs UI Route + +**Widgets** extend existing admin pages: + +```tsx +// src/admin/widgets/custom-widget.tsx +import { defineWidgetConfig } from "@medusajs/admin-sdk" +import { DetailWidgetProps } from "@medusajs/framework/types" + +const MyWidget = ({ data }: DetailWidgetProps) => { + return Widget content +} + +export const config = defineWidgetConfig({ + zone: "product.details.after", +}) + +export default MyWidget +``` + +**UI Routes** create new admin pages: + +```tsx +// src/admin/routes/custom-page/page.tsx +import { defineRouteConfig } from "@medusajs/admin-sdk" + +const CustomPage = () => { + return
Page content
+} + +export const config = defineRouteConfig({ + label: "Custom Page", +}) + +export default CustomPage +``` + +## Common Issues & Solutions + +**"Cannot find module" errors (pnpm users):** +- Install peer dependencies BEFORE coding +- Use exact versions from dashboard + +**"No QueryClient set" error:** +- pnpm: Install @tanstack/react-query +- npm/yarn: Remove incorrectly installed package + +**"DataTable.Search not enabled":** +- Must pass search configuration to useDataTable + +**Widget not refreshing:** +- Invalidate display queries, not just modal queries +- Include all dependencies in query keys + +**Display empty on refresh:** +- Display query has conditional `enabled` based on UI state +- Remove condition - display data must load on mount + +## Next Steps - Testing Your Implementation + +**After successfully implementing a feature, always provide these next steps to the user:** + +### 1. Start the Development Server + +If the server isn't already running, start it: + +```bash +npm run dev # or pnpm dev / yarn dev +``` + +### 2. Access the Admin Dashboard + +Open your browser and navigate to: +- **Admin Dashboard:** http://localhost:9000/app + +Log in with your admin credentials. + +### 3. Navigate to Your Custom UI + +**For Widgets:** +Navigate to the page where your widget is displayed. Common widget zones: +- **Product widgets:** Go to Products → Select a product → Your widget appears in the zone you configured (e.g., `product.details.after`) +- **Order widgets:** Go to Orders → Select an order → Your widget appears in the configured zone +- **Customer widgets:** Go to Customers → Select a customer → Your widget appears in the configured zone + +**For UI Routes (Custom Pages):** +- Look for your custom page in the admin sidebar/navigation (based on the `label` you configured) +- Or navigate directly to: `http://localhost:9000/app/[your-route-path]` + +### 4. Test Functionality + +Depending on what was implemented, test: +- **Forms:** Try creating/editing entities, verify validation and error messages +- **Tables:** Test pagination, search, sorting, and row selection +- **Data display:** Verify data loads correctly and refreshes after mutations +- **Modals:** Open FocusModal/Drawer, test form submission, verify data updates +- **Navigation:** Click links and verify routing works correctly + +### Format for Presenting Next Steps + +Always present next steps in a clear, actionable format after implementation: + +```markdown +## Implementation Complete + +The [feature name] has been successfully implemented. Here's how to see it: + +### Start the Development Server +[command based on package manager] + +### Access the Admin Dashboard +Open http://localhost:9000/app in your browser and log in. + +### View Your Custom UI + +**For Widgets:** +1. Navigate to [specific admin page, e.g., "Products"] +2. Select [an entity, e.g., "any product"] +3. Scroll to [zone location, e.g., "the bottom of the page"] +4. You'll see your "[widget name]" widget + +**For UI Routes:** +1. Look for "[page label]" in the admin navigation +2. Or navigate directly to http://localhost:9000/app/[route-path] + +### What to Test +1. [Specific test case 1] +2. [Specific test case 2] +3. [Specific test case 3] +``` diff --git a/.agents/skills/building-admin-dashboard-customizations/references/data-loading.md b/.agents/skills/building-admin-dashboard-customizations/references/data-loading.md new file mode 100644 index 0000000..25e1c7a --- /dev/null +++ b/.agents/skills/building-admin-dashboard-customizations/references/data-loading.md @@ -0,0 +1,530 @@ +# Data Loading Principles and Patterns + +## Contents +- [Fundamental Rules](#fundamental-rules) +- [Think Before You Code Checklist](#think-before-you-code-checklist) +- [Common Mistake vs Correct Pattern](#common-mistake-vs-correct-pattern) +- [Working with Tanstack Query](#working-with-tanstack-query) +- [Fetching Data with useQuery](#fetching-data-with-usequery) + - [Basic Query](#basic-query) + - [Paginated Query](#paginated-query) + - [Query with Dependencies](#query-with-dependencies) + - [Fetching Multiple Items by IDs](#fetching-multiple-items-by-ids) +- [Updating Data with useMutation](#updating-data-with-usemutation) + - [Basic Mutation](#basic-mutation) + - [Mutation with Loading State](#mutation-with-loading-state) + - [Create Mutation](#create-mutation) + - [Delete Mutation](#delete-mutation) +- [Cache Invalidation Guidelines](#cache-invalidation-guidelines) +- [Important Notes about Metadata](#important-notes-about-metadata) +- [Common Patterns](#common-patterns) + - [Pattern: Fetching Data with Pagination](#pattern-fetching-data-with-pagination) + - [Pattern: Search with Debounce](#pattern-search-with-debounce) + - [Pattern: Updating Metadata with useMutation](#pattern-updating-metadata-with-usemutation) +- [Common Issues & Solutions](#common-issues--solutions) +- [Complete Example: Widget with Separate Queries](#complete-example-widget-with-separate-queries) + +## Fundamental Rules + +1. **ALWAYS use the Medusa JS SDK** - NEVER use regular fetch() for API requests (missing headers causes authentication/authorization errors) +2. **Display data must load on mount** - Any data shown in the widget's main UI must be fetched when the component mounts, not conditionally +3. **Separate concerns** - Modal/form data queries should be independent from display data queries +4. **Handle reference data properly** - When storing IDs/references (in metadata or elsewhere), you must fetch the full entities to display them +5. **Always show loading states** - Users should see loading indicators, not empty states, while data is being fetched +6. **Invalidate the right queries** - After mutations, invalidate the queries that provide display data, not just the modal queries + +## Think Before You Code Checklist + +Before implementing any widget that displays data: + +- [ ] Am I using the Medusa JS SDK for all API requests (not regular fetch)? +- [ ] For built-in endpoints, am I using existing SDK methods (not sdk.client.fetch)? +- [ ] What data needs to be visible immediately? +- [ ] Where is this data stored? (metadata, separate endpoint, related entities) +- [ ] If storing IDs, how will I fetch the full entities for display? +- [ ] Are my display queries separate from interaction queries? +- [ ] Have I added loading states for all data fetches? +- [ ] Which queries need invalidation after updates to refresh the display? + +## Common Mistake vs Correct Pattern + +### ❌ WRONG - Single query for both display and modal: + +```tsx +// This breaks on page refresh! +const { data } = useQuery({ + queryFn: () => sdk.admin.product.list(), + enabled: modalOpen, // Display won't work on mount! +}) + +// Trying to display filtered data from modal query +const displayItems = data?.filter((item) => ids.includes(item.id)) // No data until modal opens +``` + +**Why this is wrong:** +- On page refresh, modal is closed, so query doesn't run +- User sees empty state instead of their data +- Display depends on modal interaction + +### ✅ CORRECT - Separate queries with proper invalidation: + +```tsx +// Display data - loads immediately +const { data: displayData } = useQuery({ + queryFn: () => fetchDisplayData(), + queryKey: ["display-data", product.id], + // No 'enabled' condition - loads on mount +}) + +// Modal data - loads when needed +const { data: modalData } = useQuery({ + queryFn: () => fetchModalData(), + queryKey: ["modal-data"], + enabled: modalOpen, // OK for modal-only data +}) + +// Mutation with proper cache invalidation +const updateMutation = useMutation({ + mutationFn: updateFunction, + onSuccess: () => { + // Invalidate display data query to refresh UI + queryClient.invalidateQueries({ queryKey: ["display-data", product.id] }) + // Also invalidate the entity if it caches the data + queryClient.invalidateQueries({ queryKey: ["product", product.id] }) + }, +}) +``` + +**Why this is correct:** +- Display query runs immediately on component mount +- Modal query only runs when needed +- Proper invalidation ensures UI updates after changes +- Each query has a clear, separate responsibility + +## Using the Medusa JS SDK + +**⚠️ CRITICAL: ALWAYS use the Medusa JS SDK for ALL API requests - NEVER use regular fetch()** + +### Why the SDK is Required + +- **Admin routes** require `Authorization` header and session cookie - SDK adds them automatically +- **Store routes** require `x-publishable-api-key` header - SDK adds them automatically +- **Regular fetch()** doesn't include these headers → authentication/authorization errors +- Using existing SDK methods provides better type safety and autocomplete + +### When to Use What + +```tsx +import { sdk } from "../lib/client" + +// ✅ CORRECT - Built-in endpoint: Use existing SDK method +const product = await sdk.admin.product.retrieve(productId, { + fields: "+metadata,+variants.*" +}) + +// ✅ CORRECT - Custom endpoint: Use sdk.client.fetch() +const reviews = await sdk.client.fetch(`/admin/products/${productId}/reviews`) + +// ❌ WRONG - Using regular fetch for ANY endpoint +const response = await fetch(`http://localhost:9000/admin/products/${productId}`) +// ❌ Error: Missing Authorization header! +``` + +### SDK Method Selection + +**For built-in Medusa endpoints:** +- Use existing SDK methods: `sdk.admin.product.list()`, `sdk.store.product.list()`, etc. +- Provides type safety, autocomplete, and proper header handling +- Reference: [Medusa JS SDK Documentation](https://docs.medusajs.com/resources/medusa-js-sdk) + +**For custom API routes:** +- Use `sdk.client.fetch()` for your custom endpoints +- SDK still handles all required headers (auth, API keys) +- Pass plain objects to body (SDK handles JSON serialization) + +## Working with Tanstack Query + +Admin widgets and routes have Tanstack Query pre-configured. + +**⚠️ pnpm Users**: You MUST install `@tanstack/react-query` BEFORE using `useQuery` or `useMutation`. Install with exact version from dashboard: + +```bash +pnpm list @tanstack/react-query --depth=10 | grep @medusajs/dashboard +pnpm add @tanstack/react-query@[exact-version] +``` + +**npm/yarn Users**: DO NOT install `@tanstack/react-query` - it's already available through dashboard dependencies. + +## Fetching Data with useQuery + +### Basic Query + +```tsx +import { useQuery } from "@tanstack/react-query" +import { sdk } from "../lib/client" + +const { data, isLoading, error } = useQuery({ + queryFn: () => sdk.admin.product.retrieve(productId, { + fields: "+metadata,+variants.*", + }), + queryKey: ["product", productId], +}) +``` + +### Paginated Query + +```tsx +const limit = 15 +const offset = pagination.pageIndex * limit + +const { data: products } = useQuery({ + queryFn: () => + sdk.admin.product.list({ + limit, + offset, + q: searchTerm, // for search + }), + queryKey: ["products", limit, offset, searchTerm], + keepPreviousData: true, // Prevents UI flicker during pagination +}) +``` + +### Query with Dependencies + +```tsx +// Only fetch if productId exists +const { data } = useQuery({ + queryFn: () => sdk.admin.product.retrieve(productId), + queryKey: ["product", productId], + enabled: !!productId, // Only run when productId is truthy +}) +``` + +### Fetching Multiple Items by IDs + +```tsx +// For display - fetch specific items by IDs +const { data: displayProducts } = useQuery({ + queryFn: async () => { + if (selectedIds.length === 0) return { products: [] } + + const response = await sdk.admin.product.list({ + id: selectedIds, // Fetch only the selected products + limit: selectedIds.length, + }) + return response + }, + queryKey: ["related-products-display", selectedIds], + enabled: selectedIds.length > 0, // Only fetch if there are IDs +}) +``` + +## Updating Data with useMutation + +### Basic Mutation + +```tsx +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { toast } from "@medusajs/ui" + +const queryClient = useQueryClient() + +const updateProduct = useMutation({ + mutationFn: (payload) => sdk.admin.product.update(productId, payload), + onSuccess: () => { + // Invalidate and refetch + queryClient.invalidateQueries({ queryKey: ["product", productId] }) + toast.success("Product updated successfully") + }, + onError: (error) => { + toast.error(error.message || "Failed to update product") + }, +}) + +// Usage +const handleSave = () => { + updateProduct.mutate({ + metadata: { + ...existingMetadata, + new_field: "value", + }, + }) +} +``` + +### Mutation with Loading State + +```tsx + +``` + +### Create Mutation + +```tsx +const createProduct = useMutation({ + mutationFn: (data) => sdk.admin.product.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["products"] }) + toast.success("Product created successfully") + setOpen(false) + }, +}) +``` + +### Delete Mutation + +```tsx +const deleteProduct = useMutation({ + mutationFn: (id) => sdk.admin.product.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["products"] }) + toast.success("Product deleted") + }, +}) +``` + +## Cache Invalidation Guidelines + +After mutations, invalidate the queries that affect what the user sees: + +```tsx +onSuccess: () => { + // Invalidate the entity itself if it stores the data + queryClient.invalidateQueries({ queryKey: ["product", productId] }) + + // Invalidate display-specific queries + queryClient.invalidateQueries({ queryKey: ["related-products", productId] }) + + // Don't need to invalidate modal selection queries + // queryClient.invalidateQueries({ queryKey: ["products-list"] }) // Not needed +} +``` + +**Key Points:** + +- Use specific query keys with IDs for targeted invalidation +- Invalidate both the entity and display data queries when needed +- Consider what the user sees and ensure those queries refresh +- Modal/selection queries typically don't need invalidation + +## Important Notes about Metadata + +- When updating nested objects in metadata, pass the entire object (Medusa doesn't merge nested objects) +- To remove a metadata property, set it to an empty string +- Metadata is stored as JSONB in the database + +**Example: Updating Metadata** + +```tsx +// ✅ CORRECT - Spread existing metadata +updateProduct.mutate({ + metadata: { + ...product.metadata, + new_field: "value", + }, +}) + +// ❌ WRONG - Overwrites all metadata +updateProduct.mutate({ + metadata: { + new_field: "value", // All other fields lost! + }, +}) +``` + +## Common Patterns + +### Pattern: Fetching Data with Pagination + +```tsx +const limit = 15 +const offset = pagination.pageIndex * limit + +const { data } = useQuery({ + queryFn: () => sdk.admin.product.list({ limit, offset }), + queryKey: ["products", limit, offset], + keepPreviousData: true, // Prevents UI flicker during pagination +}) +``` + +### Pattern: Search with Debounce + +```tsx +import { useDebouncedValue } from "@mantine/hooks" // or implement your own + +const [search, setSearch] = useState("") +const [debouncedSearch] = useDebouncedValue(search, 300) + +const { data } = useQuery({ + queryFn: () => sdk.admin.product.list({ q: debouncedSearch }), + queryKey: ["products", debouncedSearch], +}) +``` + +### Pattern: Updating Metadata with useMutation + +```tsx +const updateMetadata = useMutation({ + mutationFn: (metadata) => sdk.admin.product.update(productId, { metadata }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["product", productId] }) + toast.success("Updated successfully") + }, +}) +``` + +## Common Issues & Solutions + +### Authentication/Authorization errors when fetching data + +**Symptoms:** +- API returns 401 Unauthorized or 403 Forbidden +- "Missing x-publishable-api-key header" error +- "Unauthorized" error on admin routes + +**Cause:** Using regular `fetch()` instead of the Medusa JS SDK + +**Solution:** + +```tsx +// ❌ WRONG - Missing required headers +const { data } = useQuery({ + queryFn: () => fetch('http://localhost:9000/admin/products').then(r => r.json()), + queryKey: ["products"] +}) + +// ✅ CORRECT - SDK handles headers automatically +const { data } = useQuery({ + queryFn: () => sdk.admin.product.list(), + queryKey: ["products"] +}) + +// ✅ CORRECT - For custom routes +const { data } = useQuery({ + queryFn: () => sdk.client.fetch('/admin/custom-route'), + queryKey: ["custom-data"] +}) +``` + +### "No QueryClient set, use QueryClientProvider to set one" + +- **pnpm users**: You forgot to install `@tanstack/react-query` before implementing. Install it now with the exact version from dashboard +- **npm/yarn users**: You incorrectly installed `@tanstack/react-query` - remove it from package.json +- Never wrap your component in QueryClientProvider - it's already provided + +### Search not filtering results + +- The search happens server-side via the `q` parameter +- Make sure to pass the search value in your queryFn: + +```tsx +queryFn: () => sdk.admin.product.list({ q: searchValue }) +``` + +### Metadata updates not working + +- Always pass the complete metadata object (partial updates aren't merged) +- To remove a field, set it to an empty string, not null or undefined + +### Widget not refreshing after mutation + +- Use queryClient.invalidateQueries() with the correct query key +- Ensure your query key includes all dependencies (search, pagination, etc.) + +### Data shows empty on page refresh + +- Your query has `enabled: modalOpen` or similar condition +- Display data should NEVER be conditionally enabled based on UI state +- Move conditional queries to modals/forms only + +## Complete Example: Widget with Separate Queries + +```tsx +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { useState, useMemo } from "react" +import { Container, Heading, Button, FocusModal, toast } from "@medusajs/ui" +import { sdk } from "../lib/client" + +const RelatedProductsWidget = ({ data: product }) => { + const [open, setOpen] = useState(false) + const queryClient = useQueryClient() + + // Parse existing related product IDs from metadata + const relatedIds = useMemo(() => { + if (product?.metadata?.related_product_ids) { + try { + const ids = JSON.parse(product.metadata.related_product_ids) + return Array.isArray(ids) ? ids : [] + } catch { + return [] + } + } + return [] + }, [product?.metadata?.related_product_ids]) + + // Query 1: Fetch selected products for display (loads on mount) + const { data: displayProducts } = useQuery({ + queryFn: async () => { + if (relatedIds.length === 0) return { products: [] } + const response = await sdk.admin.product.list({ + id: relatedIds, + limit: relatedIds.length, + }) + return response + }, + queryKey: ["related-products-display", relatedIds], + enabled: relatedIds.length > 0, + }) + + // Query 2: Fetch products for modal selection (only when modal is open) + const { data: modalProducts, isLoading } = useQuery({ + queryFn: () => sdk.admin.product.list({ limit: 10, offset: 0 }), + queryKey: ["products-selection"], + enabled: open, // Only load when modal is open + }) + + // Mutation to update the product metadata + const updateProduct = useMutation({ + mutationFn: (relatedProductIds) => { + return sdk.admin.product.update(product.id, { + metadata: { + ...product.metadata, + related_product_ids: JSON.stringify(relatedProductIds), + }, + }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["product", product.id] }) + queryClient.invalidateQueries({ queryKey: ["related-products-display"] }) + toast.success("Related products updated") + setOpen(false) + }, + }) + + return ( + +
+ Related Products + +
+ + {/* Display current selection */} +
+ {displayProducts?.products.map((p) => ( +
{p.title}
+ ))} +
+ + {/* Modal for selection */} + + {/* Modal content with selection UI */} + +
+ ) +} +``` diff --git a/.agents/skills/building-admin-dashboard-customizations/references/display-patterns.md b/.agents/skills/building-admin-dashboard-customizations/references/display-patterns.md new file mode 100644 index 0000000..0e8a086 --- /dev/null +++ b/.agents/skills/building-admin-dashboard-customizations/references/display-patterns.md @@ -0,0 +1,436 @@ +# Displaying Entities - Patterns and Components + +## Contents +- [When to Use Each Pattern](#when-to-use-each-pattern) +- [DataTable Pattern](#datatable-pattern) + - [Complete DataTable Implementation](#complete-datatable-implementation) + - [DataTable Troubleshooting](#datatable-troubleshooting) +- [Simple List Patterns](#simple-list-patterns) + - [Product/Variant List Item](#productvariant-list-item) + - [Simple Text List (No Thumbnails)](#simple-text-list-no-thumbnails) + - [Compact List (No Cards)](#compact-list-no-cards) + - [Grid Display](#grid-display) +- [Key Design Elements](#key-design-elements) +- [Empty States](#empty-states) +- [Loading States](#loading-states) +- [Conditional Rendering Based on Count](#conditional-rendering-based-on-count) +- [Common Class Patterns](#common-class-patterns) + +## When to Use Each Pattern + +**Use DataTable when:** + +- Displaying potentially many entries (>5-10 items) +- Users need to search, filter, or paginate +- Bulk actions are needed (select multiple, delete, etc.) +- Displaying in a main list view + +**Use simple list components when:** + +- Displaying a few entries (<5-10 items) +- In a widget or sidebar context +- As a preview or summary +- Space is limited + +## DataTable Pattern + +**⚠️ pnpm Users**: DataTable examples may use `react-router-dom` for navigation. Install it BEFORE implementing if needed. + +### Complete DataTable Implementation + +```tsx +import { + DataTable, + DataTableRowSelectionState, + DataTablePaginationState, + createDataTableColumnHelper, + useDataTable, +} from "@medusajs/ui" +import { useState, useMemo } from "react" +import { useQuery } from "@tanstack/react-query" +import { sdk } from "../lib/client" + +const columnHelper = createDataTableColumnHelper() + +const columns = [ + columnHelper.select(), // For row selection + columnHelper.accessor("title", { + header: "Title", + }), + columnHelper.accessor("status", { + header: "Status", + }), + columnHelper.accessor("created_at", { + header: "Created", + cell: ({ getValue }) => new Date(getValue()).toLocaleDateString(), + }), +] + +export function ProductTable() { + const [rowSelection, setRowSelection] = useState( + {} + ) + const [searchValue, setSearchValue] = useState("") + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 15, + }) + + const limit = pagination.pageSize + const offset = pagination.pageIndex * limit + + // Fetch products with search and pagination + const { data, isLoading } = useQuery({ + queryFn: () => + sdk.admin.product.list({ + limit, + offset, + q: searchValue || undefined, // Search query + }), + queryKey: ["products", limit, offset, searchValue], + keepPreviousData: true, // Smooth pagination + }) + + const table = useDataTable({ + data: data?.products || [], + columns, + getRowId: (product) => product.id, + rowCount: data?.count || 0, + isLoading, + rowSelection: { + state: rowSelection, + onRowSelectionChange: setRowSelection, + }, + search: { + state: searchValue, + onSearchChange: setSearchValue, + }, + pagination: { + state: pagination, + onPaginationChange: setPagination, + }, + }) + + return ( + + +
+ +
+
+ + +
+ ) +} +``` + +### DataTable Troubleshooting + +**"DataTable.Search was rendered but search is not enabled"** + +You must pass search state configuration to useDataTable: + +```tsx +search: { + state: searchValue, + onSearchChange: setSearchValue, +} +``` + +**"Cannot destructure property 'pageIndex' of pagination as it is undefined"** + +Always initialize pagination state with both properties: + +```tsx +const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 15, +}) +``` + +## Simple List Patterns + +### Product/Variant List Item + +For displaying a small list of products or variants with thumbnails: + +```tsx +import { Thumbnail, Text } from "@medusajs/ui" +import { TriangleRightMini } from "@medusajs/icons" +import { Link } from "react-router-dom" + +// Component for displaying a product variant +const ProductVariantItem = ({ variant, link }) => { + const Inner = ( +
+
+
+ +
+
+ + {variant.title} + + + {variant.options.map((o) => o.value).join(" ⋅ ")} + +
+
+ +
+
+
+ ) + + if (!link) { + return
{Inner}
+ } + + return ( + + {Inner} + + ) +} + +// Usage in a widget +const RelatedProductsDisplay = ({ products }) => { + if (products.length > 10) { + // Use DataTable for many items + return + } + + // Use simple list for few items + return ( +
+ {products.map((product) => ( + + ))} +
+ ) +} +``` + +### Simple Text List (No Thumbnails) + +For entities without images (categories, regions, etc.): + +```tsx +import { Text } from "@medusajs/ui" +import { TriangleRightMini } from "@medusajs/icons" +import { Link } from "react-router-dom" + +const SimpleListItem = ({ title, description, link }) => { + const Inner = ( +
+
+
+ + {title} + + {description && ( + + {description} + + )} +
+
+ +
+
+
+ ) + + if (!link) { + return
{Inner}
+ } + + return ( + + {Inner} + + ) +} + +// Usage +
+ {categories.map((cat) => ( + + ))} +
+``` + +### Compact List (No Cards) + +For very compact displays: + +```tsx +import { Text } from "@medusajs/ui" + +
+ {items.map((item) => ( +
+ + {item.title} + + + {item.metadata} + +
+ ))} +
+``` + +### Grid Display + +For displaying items in a grid: + +```tsx +
+ {items.map((item) => ( +
+
+ + + {item.title} + + + {item.description} + +
+
+ ))} +
+``` + +## Key Design Elements + +### For Product/Variant displays: + +- Always show the thumbnail using `` component +- Display title with `` +- Show secondary info with `` +- Use `shadow-elevation-card-rest` for card elevation +- Include hover states with `bg-ui-bg-component-hover` +- Add navigation indicators (arrows) when items are clickable + +### For other entities: + +- Use similar card patterns but adapt the content +- Keep consistent spacing (`gap-3` for items, `gap-2` for lists) +- Always use the Text component with correct typography patterns +- Maintain visual hierarchy with `weight="plus"` for primary and `text-ui-fg-subtle` for secondary text + +## Empty States + +Always handle empty states gracefully: + +```tsx +{items.length === 0 ? ( + + No items to display + +) : ( +
+ {items.map((item) => ( + + ))} +
+)} +``` + +## Loading States + +Show loading states while data is being fetched: + +```tsx +import { Spinner } from "@medusajs/ui" + +{isLoading ? ( +
+ +
+) : ( +
+ {items.map((item) => ( + + ))} +
+)} +``` + +## Conditional Rendering Based on Count + +```tsx +const DisplayComponent = ({ items }) => { + // Use DataTable for many items + if (items.length > 10) { + return + } + + // Use simple list for few items + if (items.length > 0) { + return ( +
+ {items.map((item) => ( + + ))} +
+ ) + } + + // Empty state + return ( + + No items to display + + ) +} +``` + +## Common Class Patterns + +### Card with elevation and hover + +```tsx +className="shadow-elevation-card-rest bg-ui-bg-component rounded-md transition-colors hover:bg-ui-bg-component-hover" +``` + +### Flex container with consistent spacing + +```tsx +className="flex flex-col gap-2" // For vertical lists +className="flex items-center gap-3" // For horizontal items +``` + +### Focus states for interactive elements + +```tsx +className="outline-none focus-within:shadow-borders-interactive-with-focus rounded-md" +``` + +### RTL support for directional icons + +```tsx +className="text-ui-fg-muted rtl:rotate-180" +``` diff --git a/.agents/skills/building-admin-dashboard-customizations/references/forms.md b/.agents/skills/building-admin-dashboard-customizations/references/forms.md new file mode 100644 index 0000000..12958a5 --- /dev/null +++ b/.agents/skills/building-admin-dashboard-customizations/references/forms.md @@ -0,0 +1,400 @@ +# Forms and Modal Patterns + +## Contents +- [FocusModal vs Drawer](#focusmodal-vs-drawer) +- [Edit Button Patterns](#edit-button-patterns) + - [Simple Edit Button (top right corner)](#simple-edit-button-top-right-corner) + - [Dropdown Menu with Actions](#dropdown-menu-with-actions) +- [Select Component for Small Datasets](#select-component-for-small-datasets) +- [FocusModal Example](#focusmodal-example) +- [Drawer Example](#drawer-example) +- [Form with Validation and Loading States](#form-with-validation-and-loading-states) +- [Key Form Patterns](#key-form-patterns) + +## FocusModal vs Drawer + +**FocusModal** - Use for creating new entities: +- Full-screen modal +- More space for complex forms +- Better for multi-step flows + +**Drawer** - Use for editing existing entities: +- Side panel that slides in from right +- Quick edits without losing context +- Better for single-field updates + +**Rule of thumb:** FocusModal for creating, Drawer for editing. + +## Edit Button Patterns + +Data displayed in a container should not be editable directly. Instead, use an "Edit" button. This can be: + +### Simple Edit Button (top right corner) + +```tsx +import { Button } from "@medusajs/ui" +import { PencilSquare } from "@medusajs/icons" + +
+ Section Title + +
+``` + +### Dropdown Menu with Actions + +```tsx +import { EllipsisHorizontal, PencilSquare, Plus, Trash } from "@medusajs/icons" +import { DropdownMenu, IconButton } from "@medusajs/ui" + +export function DropdownMenuDemo() { + return ( + + + + + + + + + + Edit + + + + Add + + + + + Delete + + + + ) +} +``` + +## Select Component for Small Datasets + +For selecting from 2-10 options (statuses, types, etc.), use the Select component: + +```tsx +import { Select } from "@medusajs/ui" + + +``` + +**For larger datasets** (Products, Categories, Regions, etc.), use DataTable with FocusModal for search and pagination. See [table-selection.md](table-selection.md) for the complete pattern. + +## FocusModal Example + +```tsx +import { FocusModal, Button, Input, Label } from "@medusajs/ui" +import { useState } from "react" + +const MyWidget = () => { + const [open, setOpen] = useState(false) + const [formData, setFormData] = useState({ title: "" }) + + const handleSubmit = () => { + // Handle form submission + console.log(formData) + setOpen(false) + } + + return ( + <> + + + + +
+ +
+ + + + +
+
+ + +
+
+ + setFormData({ ...formData, title: e.target.value })} + /> +
+ {/* More form fields */} +
+
+
+
+
+ + ) +} +``` + +## Drawer Example + +```tsx +import { Drawer, Button, Input, Label } from "@medusajs/ui" +import { useState } from "react" + +const MyWidget = ({ data }) => { + const [open, setOpen] = useState(false) + const [formData, setFormData] = useState({ title: data.title }) + + const handleSubmit = () => { + // Handle form submission + console.log(formData) + setOpen(false) + } + + return ( + <> + + + + + + Edit Settings + + + +
+
+ + setFormData({ ...formData, title: e.target.value })} + /> +
+ {/* More form fields */} +
+
+ + +
+ + + + +
+
+
+
+ + ) +} +``` + +## Form with Validation and Loading States + +```tsx +import { FocusModal, Button, Input, Label, Text, toast } from "@medusajs/ui" +import { useState } from "react" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { sdk } from "../lib/client" + +const CreateProductWidget = () => { + const [open, setOpen] = useState(false) + const [formData, setFormData] = useState({ + title: "", + description: "", + }) + const [errors, setErrors] = useState({}) + const queryClient = useQueryClient() + + const createProduct = useMutation({ + mutationFn: (data) => sdk.admin.product.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["products"] }) + toast.success("Product created successfully") + setOpen(false) + setFormData({ title: "", description: "" }) + setErrors({}) + }, + onError: (error) => { + toast.error(error.message || "Failed to create product") + }, + }) + + const handleSubmit = () => { + // Validate + const newErrors = {} + if (!formData.title) newErrors.title = "Title is required" + if (!formData.description) newErrors.description = "Description is required" + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors) + return + } + + createProduct.mutate(formData) + } + + return ( + <> + + + + +
+ +
+ + + + +
+
+ + +
+
+ + { + setFormData({ ...formData, title: e.target.value }) + setErrors({ ...errors, title: undefined }) + }} + /> + {errors.title && ( + + {errors.title} + + )} +
+ +
+ + { + setFormData({ ...formData, description: e.target.value }) + setErrors({ ...errors, description: undefined }) + }} + /> + {errors.description && ( + + {errors.description} + + )} +
+
+
+
+
+
+ + ) +} +``` + +## Key Form Patterns + +### Always Disable Actions During Mutations + +```tsx + +``` + +### Show Loading State on Submit Button + +```tsx + +``` + +### Clear Form After Success + +```tsx +onSuccess: () => { + setFormData(initialState) + setErrors({}) + setOpen(false) +} +``` + +### Validate Before Submitting + +```tsx +const handleSubmit = () => { + const errors = validateForm(formData) + if (Object.keys(errors).length > 0) { + setErrors(errors) + return + } + mutation.mutate(formData) +} +``` + +### Clear Field Errors on Input Change + +```tsx + { + setFormData({ ...formData, field: e.target.value }) + setErrors({ ...errors, field: undefined }) // Clear error + }} +/> +``` diff --git a/.agents/skills/building-admin-dashboard-customizations/references/navigation.md b/.agents/skills/building-admin-dashboard-customizations/references/navigation.md new file mode 100644 index 0000000..476e1f4 --- /dev/null +++ b/.agents/skills/building-admin-dashboard-customizations/references/navigation.md @@ -0,0 +1,496 @@ +# Navigation and Routing + +## Contents +- [Pre-Implementation Requirements for pnpm](#pre-implementation-requirements-for-pnpm) +- [Basic Navigation with Link Component](#basic-navigation-with-link-component) +- [Programmatic Navigation](#programmatic-navigation) +- [Accessing Route Parameters](#accessing-route-parameters) +- [Linking to Built-in Admin Pages](#linking-to-built-in-admin-pages) +- [Navigation from Widgets](#navigation-from-widgets) +- [Common Navigation Patterns](#common-navigation-patterns) + +## Pre-Implementation Requirements for pnpm + +**⚠️ pnpm Users**: Navigation requires `react-router-dom`. Install BEFORE implementing: + +```bash +pnpm list react-router-dom --depth=10 | grep @medusajs/dashboard +pnpm add react-router-dom@[exact-version] +``` + +**npm/yarn Users**: DO NOT install - already available through dashboard dependencies. + +## Basic Navigation with Link Component + +Use the `Link` component for internal navigation in widgets and custom pages: + +```tsx +import { Link } from "react-router-dom" +import { Text } from "@medusajs/ui" +import { TriangleRightMini } from "@medusajs/icons" + +// Link to a custom page + +
+
+ + Go to Custom Page + +
+ +
+
+
+ +``` + +### Link with Dynamic ID + +```tsx +// Link to product details + + + {product.title} + + +``` + +### Button-styled Link + +```tsx +import { Button } from "@medusajs/ui" +import { Link } from "react-router-dom" + + +``` + +## Programmatic Navigation + +Use `useNavigate` for navigation after actions (e.g., after creating an entity): + +```tsx +import { useNavigate } from "react-router-dom" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { toast, Button } from "@medusajs/ui" +import { sdk } from "../lib/client" + +const CreateProductWidget = () => { + const navigate = useNavigate() + const queryClient = useQueryClient() + + const createProduct = useMutation({ + mutationFn: (data) => sdk.admin.product.create(data), + onSuccess: (result) => { + queryClient.invalidateQueries({ queryKey: ["products"] }) + toast.success("Product created successfully") + + // Navigate to the new product's page + navigate(`/products/${result.product.id}`) + }, + onError: (error) => { + toast.error(error.message || "Failed to create product") + }, + }) + + const handleCreate = () => { + createProduct.mutate({ + title: "New Product", + // ... other fields + }) + } + + return ( + + ) +} +``` + +### Navigate with State + +Pass data to the destination page: + +```tsx +navigate("/custom/review", { + state: { productId: product.id, productTitle: product.title } +}) + +// Access in destination page +import { useLocation } from "react-router-dom" + +const ReviewPage = () => { + const location = useLocation() + const { productId, productTitle } = location.state || {} + + return
Reviewing: {productTitle}
+} +``` + +### Navigate Back + +```tsx +const navigate = useNavigate() + + +``` + +## Accessing Route Parameters + +In custom pages, access URL parameters with `useParams`: + +```tsx +// Custom page at: /custom/products/:id +import { useParams } from "react-router-dom" +import { useQuery } from "@tanstack/react-query" +import { sdk } from "../lib/client" +import { Container, Heading } from "@medusajs/ui" + +const ProductDetailsPage = () => { + const { id } = useParams() // Get :id from URL + + const { data: product, isLoading } = useQuery({ + queryFn: () => sdk.admin.product.retrieve(id, { + fields: "+metadata,+variants.*", + }), + queryKey: ["product", id], + enabled: !!id, // Only fetch if ID exists + }) + + if (isLoading) return
Loading...
+ + return ( + + {product?.title} + {/* Product details */} + + ) +} + +export default ProductDetailsPage +``` + +### Multiple Parameters + +```tsx +// Route: /custom/orders/:orderId/items/:itemId +const { orderId, itemId } = useParams() + +const { data } = useQuery({ + queryFn: () => sdk.client.fetch(`/admin/orders/${orderId}/items/${itemId}`), + queryKey: ["order-item", orderId, itemId], + enabled: !!orderId && !!itemId, +}) +``` + +### Query Parameters + +Use `useSearchParams` for query string parameters: + +```tsx +import { useSearchParams } from "react-router-dom" + +const ProductsPage = () => { + const [searchParams, setSearchParams] = useSearchParams() + + const status = searchParams.get("status") // Get ?status=published + const page = searchParams.get("page") // Get ?page=2 + + const { data } = useQuery({ + queryFn: () => sdk.admin.product.list({ + status, + offset: (parseInt(page) || 0) * 15, + }), + queryKey: ["products", status, page], + }) + + // Update query params + const handleFilterChange = (newStatus: string) => { + setSearchParams({ status: newStatus, page: "0" }) + } + + return ( +
+ + {/* Products list */} +
+ ) +} +``` + +## Linking to Built-in Admin Pages + +Link to standard Medusa admin pages: + +```tsx +import { Link } from "react-router-dom" + +// Product details +View Product + +// Order details +View Order + +// Customer details +View Customer + +// Product categories +View Categories + +// Settings +Settings + +// Custom field in settings +Custom Settings +``` + +### Common Built-in Routes + +```tsx +const ADMIN_ROUTES = { + products: "/products", + productDetails: (id: string) => `/products/${id}`, + orders: "/orders", + orderDetails: (id: string) => `/orders/${id}`, + customers: "/customers", + customerDetails: (id: string) => `/customers/${id}`, + categories: "/categories", + inventory: "/inventory", + pricing: "/pricing", + settings: "/settings", +} + +// Usage + + View Product + +``` + +## Navigation from Widgets + +### Pattern: View All Link + +Add a "View All" link from a widget to a custom page: + +```tsx +import { defineWidgetConfig } from "@medusajs/admin-sdk" +import { Container, Heading, Button, Text } from "@medusajs/ui" +import { Link } from "react-router-dom" +import { DetailWidgetProps } from "@medusajs/framework/types" + +const RelatedProductsWidget = ({ data: product }) => { + // ... widget logic + + return ( + +
+ Related Products +
+ + +
+
+ {/* Widget content */} +
+ ) +} + +export const config = defineWidgetConfig({ + zone: "product.details.after", +}) + +export default RelatedProductsWidget +``` + +### Pattern: List Item Navigation + +Make list items clickable to navigate: + +```tsx +import { Thumbnail, Text } from "@medusajs/ui" +import { TriangleRightMini } from "@medusajs/icons" +import { Link } from "react-router-dom" + +const ProductListItem = ({ product }) => { + return ( + +
+
+ +
+ + {product.title} + + + {product.status} + +
+
+ +
+
+
+ + ) +} +``` + +## Common Navigation Patterns + +### Pattern: Back to List + +Navigate back to list after viewing details: + +```tsx +import { useNavigate } from "react-router-dom" +import { ArrowLeft } from "@medusajs/icons" +import { IconButton } from "@medusajs/ui" + +const DetailsPage = () => { + const navigate = useNavigate() + + return ( +
+
+ navigate("/custom/products")}> + + + Product Details +
+ {/* Details content */} +
+ ) +} +``` + +### Pattern: Breadcrumb Navigation + +```tsx +import { Link } from "react-router-dom" +import { Text } from "@medusajs/ui" + +const Breadcrumbs = ({ product }) => { + return ( +
+ + + Products + + + / + + + {product.title} + + + / + Details +
+ ) +} +``` + +### Pattern: Tab Navigation + +Navigate between different views using tabs: + +```tsx +import { useSearchParams, Link } from "react-router-dom" +import { Tabs } from "@medusajs/ui" + +const ProductTabs = () => { + const [searchParams] = useSearchParams() + const activeTab = searchParams.get("tab") || "details" + + return ( + + + + Details + + + Variants + + + Media + + + + + {/* Details content */} + + + {/* Variants content */} + + + {/* Media content */} + + + ) +} +``` + +### Pattern: Action with Navigation + +Perform an action then navigate: + +```tsx +const deleteProduct = useMutation({ + mutationFn: (id) => sdk.admin.product.delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["products"] }) + toast.success("Product deleted") + navigate("/products") // Navigate to list after deletion + }, +}) +``` + +### Pattern: Conditional Navigation + +Navigate based on state or data: + +```tsx +const handleComplete = () => { + if (hasErrors) { + toast.error("Please fix errors first") + return + } + + if (isDraft) { + navigate(`/custom/products/${id}/publish`) + } else { + navigate("/products") + } +} +``` + +## Important Notes + +1. **pnpm users**: Must install `react-router-dom` with exact version from dashboard +2. **npm/yarn users**: Do NOT install `react-router-dom` - already available +3. **Always use relative paths** starting with `/` for internal navigation +4. **Use Link for navigation links** - better for SEO and accessibility +5. **Use navigate for programmatic navigation** - after actions or based on logic +6. **Always handle loading states** when fetching route parameter-based data +7. **Clean up on unmount** when using listeners or subscriptions in routes +8. **Maintain focus management** for accessibility when navigating diff --git a/.agents/skills/building-admin-dashboard-customizations/references/table-selection.md b/.agents/skills/building-admin-dashboard-customizations/references/table-selection.md new file mode 100644 index 0000000..3dafaf8 --- /dev/null +++ b/.agents/skills/building-admin-dashboard-customizations/references/table-selection.md @@ -0,0 +1,407 @@ +# Table Selection Pattern + +## Contents +- [Pre-Implementation Requirements for pnpm](#pre-implementation-requirements-for-pnpm) +- [Complete Widget Example: Related Products Selection](#complete-widget-example-related-products-selection) +- [Key Implementation Details](#key-implementation-details) +- [Pattern Variations](#pattern-variations) +- [Important Notes](#important-notes) + +This is a complete reference implementation for selecting from large datasets (Products, Categories, Regions, etc.) in Medusa Admin customizations. This pattern uses FocusModal with DataTable for an optimal UX. + +## Pre-Implementation Requirements for pnpm + +**⚠️ pnpm Users**: This example requires the following packages. Install them BEFORE implementing: +- `@tanstack/react-query` - for useQuery and useMutation +- `react-router-dom` - for Link component (optional, can be removed if not needed) + +Check and install with exact versions: +```bash +pnpm list @tanstack/react-query --depth=10 | grep @medusajs/dashboard +pnpm add @tanstack/react-query@[exact-version] + +pnpm list react-router-dom --depth=10 | grep @medusajs/dashboard +pnpm add react-router-dom@[exact-version] +``` + +See the main SKILL.md for full pnpm setup instructions. + +## Complete Widget Example: Related Products Selection + +```tsx +import { defineWidgetConfig } from "@medusajs/admin-sdk" +import { + Container, + Heading, + Button, + toast, + FocusModal, + Text, + DataTable, + DataTableRowSelectionState, + DataTablePaginationState, + createDataTableColumnHelper, + useDataTable, +} from "@medusajs/ui" +import { HttpTypes } from "@medusajs/types" +import { useMemo, useState } from "react" +import { DetailWidgetProps } from "@medusajs/framework/types" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" +import { sdk } from "../lib/client" +import { PencilSquare } from "@medusajs/icons" + +const ProductRelatedProductsWidget = ({ + data: product +}: DetailWidgetProps) => { + const [open, setOpen] = useState(false) + const queryClient = useQueryClient() + + // Parse existing related products from metadata + const initialIds = useMemo(() => { + if (product?.metadata?.related_product_ids) { + try { + const ids = JSON.parse(product.metadata.related_product_ids as string) + return Array.isArray(ids) ? ids : [] + } catch { + return [] + } + } + return [] + }, [product?.metadata?.related_product_ids]) + + // Initialize selection state with existing related products + const initialState = useMemo(() => { + return initialIds.reduce((acc, id) => { + acc[id] = true + return acc + }, {} as DataTableRowSelectionState) + }, [initialIds]) + + const [rowSelection, setRowSelection] = useState(initialState) + const [searchValue, setSearchValue] = useState("") + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10 + }) + + // IMPORTANT: Separate queries for display and modal selection + + // Query 1: Fetch selected products for display (loads on mount) + const { data: displayProducts } = useQuery({ + queryFn: async () => { + if (initialIds.length === 0) return { products: [] } + // Fetch specific products by IDs for display + const response = await sdk.admin.product.list({ + id: initialIds, // Fetch only the selected products + limit: initialIds.length, + }) + return response + }, + queryKey: ["related-products-display", initialIds], + enabled: initialIds.length > 0, // Only fetch if there are IDs + }) + + // Query 2: Fetch products for modal selection (only when modal is open) + const limit = pagination.pageSize + const offset = pagination.pageIndex * limit + + const { data: modalProducts, isLoading } = useQuery({ + queryFn: () => sdk.admin.product.list({ + limit, + offset, + q: searchValue || undefined, + }), + queryKey: ["products-selection", limit, offset, searchValue], + keepPreviousData: true, // Smooth pagination + enabled: open, // Only load when modal is open + }) + + // Mutation to update the product metadata + const updateProduct = useMutation({ + mutationFn: (relatedProductIds: string[]) => { + return sdk.admin.product.update(product.id, { + metadata: { + ...product.metadata, + related_product_ids: JSON.stringify(relatedProductIds), + }, + }) + }, + onSuccess: () => { + // Invalidate queries to refresh the display + queryClient.invalidateQueries({ queryKey: ["product", product.id] }) + queryClient.invalidateQueries({ queryKey: ["related-products-display"] }) + toast.success("Success", { + description: "Related products updated successfully", + dismissLabel: "Close", + }) + setOpen(false) + }, + onError: (error) => { + console.error("Error saving related products:", error) + toast.error("Error", { + description: "Failed to update related products", + dismissLabel: "Close", + }) + }, + }) + + // Get selected product IDs + const selectedProductIds = useMemo(() => Object.keys(rowSelection), [rowSelection]) + + // Use display query for showing selected products + const selectedProducts = displayProducts?.products || [] + + const handleSubmit = () => { + updateProduct.mutate(selectedProductIds) + } + + const columns = useColumns() + + // Use modalProducts for the selection table + const availableProducts = useMemo(() => { + if (!modalProducts?.products) return [] + return modalProducts.products.filter(p => p.id !== product.id) + }, [modalProducts?.products, product.id]) + + const table = useDataTable({ + data: availableProducts, + columns, + getRowId: (row) => row.id, + rowCount: modalProducts?.count || 0, + isLoading, + rowSelection: { + state: rowSelection, + onRowSelectionChange: setRowSelection, + }, + search: { + state: searchValue, + onSearchChange: setSearchValue, + }, + pagination: { + state: pagination, + onPaginationChange: setPagination, + }, + }) + + return ( + +
+ Related Products + +
+
+ {selectedProducts.length === 0 ? ( + + No related products selected + + ) : ( +
+ {selectedProducts.map((p) => ( +
+ + {p.title} + +
+ ))} +
+ )} +
+ + + +
+ + +
+
+ + +
+ +
+
+ + +
+
+
+
+ +
+ + + + +
+
+
+
+
+
+ ) +} + +const columnHelper = createDataTableColumnHelper() + +const useColumns = () => { + return useMemo(() => [ + columnHelper.select(), + columnHelper.accessor("title", { + header: "Title", + }), + columnHelper.accessor("status", { + header: "Status", + }), + columnHelper.accessor("created_at", { + header: "Created", + cell: ({ getValue }) => new Date(getValue()).toLocaleDateString(), + }), + ], []) +} + +export const config = defineWidgetConfig({ + zone: "product.details.after", +}) + +export default ProductRelatedProductsWidget +``` + +## Key Implementation Details + +### 1. Typography +- Use `` for product titles and labels +- Use `` for descriptions +- Container headers can use `` component +- See [typography.md](typography.md) for complete typography guidelines + +### 2. State Management +- Use `DataTableRowSelectionState` for tracking selected rows +- Initialize with existing selections from metadata +- Use `DataTablePaginationState` with both `pageIndex` and `pageSize` + +### 3. Data Loading Pattern - CRITICAL +**Always use separate queries for display vs modal selection:** + +```tsx +// Display query - loads on mount, fetches specific items +const { data: displayProducts } = useQuery({ + queryFn: () => sdk.admin.product.list({ + id: initialIds, // Fetch specific products by IDs + }), + queryKey: ["related-products-display", initialIds], + enabled: initialIds.length > 0, +}) + +// Modal query - loads when modal opens, paginated +const { data: modalProducts } = useQuery({ + queryFn: () => sdk.admin.product.list({ + limit, offset, q: searchValue, + }), + queryKey: ["products-selection", limit, offset, searchValue], + enabled: open, // Only when modal is open + keepPreviousData: true, +}) +``` + +**Why this pattern?** +- Display data loads immediately on mount +- Modal data only loads when needed +- Prevents "No data" on page refresh +- Handles ID-based references properly + +### 4. Updating with useMutation and Cache Invalidation +```tsx +const updateProduct = useMutation({ + mutationFn: (payload) => sdk.admin.product.update(id, payload), + onSuccess: () => { + // Invalidate BOTH the product and display queries + queryClient.invalidateQueries({ queryKey: ["product", id] }) + queryClient.invalidateQueries({ queryKey: ["related-products-display"] }) + toast.success("Updated successfully") + + // Note: No need to invalidate ["products-selection"] - that's modal data + }, +}) +``` + +### 5. DataTable Configuration +Always provide all required configurations: +```tsx +const table = useDataTable({ + data: data?.products || [], + columns, + getRowId: (row) => row.id, + rowCount: data?.count || 0, + isLoading, + rowSelection: { /* config */ }, + search: { /* config */ }, + pagination: { /* config */ }, +}) +``` + +### 6. Component Structure +```tsx + + +
+ +
+
+ + +
+``` + +## Pattern Variations + +### For Categories Selection +```tsx +const { data, isLoading } = useQuery({ + queryFn: () => sdk.admin.productCategory.list({ limit, offset }), + queryKey: ["categories", limit, offset], +}) +``` + +### For Regions Selection +```tsx +const { data, isLoading } = useQuery({ + queryFn: () => sdk.admin.region.list({ limit, offset }), + queryKey: ["regions", limit, offset], +}) +``` + +### For Custom Endpoints +```tsx +const { data, isLoading } = useQuery({ + queryFn: () => sdk.client.fetch("/admin/custom-endpoint", { + query: { limit, offset, search: searchValue } + }), + queryKey: ["custom-data", limit, offset, searchValue], +}) +``` + +## Important Notes + +1. **Package Manager Considerations**: + - **pnpm users**: MUST install `@tanstack/react-query` and `react-router-dom` BEFORE implementing (see Pre-Implementation Requirements above) + - **npm/yarn users**: DO NOT install these packages - they're already available through dashboard +2. **Always use keepPreviousData: true** for pagination to prevent UI flicker +3. **Search is server-side** - Pass the search value in the query function +4. **Metadata updates replace the entire object** - Spread existing metadata when updating +5. **Use proper query key dependencies** - Include all parameters that affect the data + +This pattern provides a consistent, performant way to handle selection from large datasets in Medusa Admin customizations. \ No newline at end of file diff --git a/.agents/skills/building-admin-dashboard-customizations/references/typography.md b/.agents/skills/building-admin-dashboard-customizations/references/typography.md new file mode 100644 index 0000000..53992bd --- /dev/null +++ b/.agents/skills/building-admin-dashboard-customizations/references/typography.md @@ -0,0 +1,210 @@ +# Typography Guidelines + +## Contents +- [Core Typography Pattern](#core-typography-pattern) +- [Typography Rules](#typography-rules) +- [Complete Examples](#complete-examples) +- [Text Color Classes](#text-color-classes) +- [Common Patterns](#common-patterns) +- [Quick Reference](#quick-reference) + +## Core Typography Pattern + +Use the `Text` component from `@medusajs/ui` for all text elements. Follow these specific patterns: + +### Headings/Labels + +Use this pattern for section headings, field labels, or any primary text: + +```tsx + + {labelText} + +``` + +### Body/Descriptions + +Use this pattern for descriptions, helper text, or secondary information: + +```tsx + + {descriptionText} + +``` + +## Typography Rules + +- **Never use** `` component for small sections within widgets/containers +- **Always use** `size="small"` and `leading="compact"` for consistency +- **Use** `weight="plus"` for labels and headings +- **Use** `className="text-ui-fg-subtle"` for secondary/descriptive text +- **For larger headings** (page titles, container headers), use the `` component + +## Complete Examples + +### Widget Section with Label and Description + +```tsx +import { Text } from "@medusajs/ui" + +// In a container or widget: +
+ + Product Settings + + + Configure how this product appears in your store + +
+``` + +### List Item with Title and Subtitle + +```tsx +
+ + Premium T-Shirt + + + Size: Large • Color: Blue + +
+``` + +### Container Header (Use Heading) + +```tsx +import { Container, Heading } from "@medusajs/ui" + + +
+ Related Products +
+ {/* ... */} +
+``` + +### Empty State Message + +```tsx + + No related products selected + +``` + +### Form Field Label + +```tsx +
+ + Display Name + + +
+``` + +### Error Message + +```tsx + + This field is required + +``` + +### Badge or Status Text + +```tsx +
+ + Status: + + + Active + +
+``` + +## Text Color Classes + +Medusa UI provides semantic color classes: + +- `text-ui-fg-base` - Default text color (rarely needed, it's the default) +- `text-ui-fg-subtle` - Secondary/muted text +- `text-ui-fg-muted` - Even more muted +- `text-ui-fg-disabled` - Disabled state +- `text-ui-fg-error` - Error messages +- `text-ui-fg-success` - Success messages +- `text-ui-fg-warning` - Warning messages + +## Common Patterns + +### Two-Column Layout + +```tsx +
+
+ + Category + + + Clothing + +
+
+ + Status + + + Published + +
+
+``` + +### Inline Label-Value Pair + +```tsx +
+ + SKU: + + + SHIRT-001 + +
+``` + +### Card with Title and Metadata + +```tsx +
+ + Premium Cotton T-Shirt + +
+ + $29.99 + + + • + + + In stock + +
+
+``` + +## Quick Reference + +| Use Case | Pattern | +|----------|---------| +| Section headings | `weight="plus"` | +| Primary text | `weight="plus"` | +| Labels | `weight="plus"` | +| Descriptions | `className="text-ui-fg-subtle"` | +| Helper text | `className="text-ui-fg-subtle"` | +| Metadata | `className="text-ui-fg-subtle"` | +| Errors | `className="text-ui-fg-error"` | +| Empty states | `className="text-ui-fg-subtle"` | +| Large headers | `` component | diff --git a/.agents/skills/building-storefronts/SKILL.md b/.agents/skills/building-storefronts/SKILL.md new file mode 100644 index 0000000..1b7e16c --- /dev/null +++ b/.agents/skills/building-storefronts/SKILL.md @@ -0,0 +1,178 @@ +--- +name: building-storefronts +description: Load automatically when planning, researching, or implementing Medusa storefront features (calling custom API routes, SDK integration, React Query patterns, data fetching). REQUIRED for all storefront development in ALL modes (planning, implementation, exploration). Contains SDK usage patterns, frontend integration, and critical rules for calling Medusa APIs. +--- + +# Medusa Storefront Development + +Frontend integration guide for building storefronts with Medusa. Covers SDK usage, React Query patterns, and calling custom API routes. + +## When to Apply + +**Load this skill for ANY storefront development task, including:** +- Calling custom Medusa API routes from the storefront +- Integrating Medusa SDK in frontend applications +- Using React Query for data fetching +- Implementing mutations with optimistic updates +- Error handling and cache invalidation + +**Also load building-with-medusa when:** Building the backend API routes that the storefront calls + +## CRITICAL: Load Reference Files When Needed + +**The quick reference below is NOT sufficient for implementation.** You MUST load the reference file before writing storefront integration code. + +**Load this reference when implementing storefront features:** + +- **Calling API routes?** → MUST load `references/frontend-integration.md` first +- **Using SDK?** → MUST load `references/frontend-integration.md` first +- **Implementing React Query?** → MUST load `references/frontend-integration.md` first + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | SDK Usage | CRITICAL | `sdk-` | +| 2 | React Query Patterns | HIGH | `query-` | +| 3 | Data Display | HIGH (includes CRITICAL price rule) | `display-` | +| 4 | Error Handling | MEDIUM | `error-` | + +## Quick Reference + +### 1. SDK Usage (CRITICAL) + +- `sdk-always-use` - **ALWAYS use the Medusa JS SDK for ALL API requests** - NEVER use regular fetch() +- `sdk-existing-methods` - For built-in endpoints, use existing SDK methods (`sdk.store.product.list()`, `sdk.admin.order.retrieve()`) +- `sdk-client-fetch` - For custom API routes, use `sdk.client.fetch()` +- `sdk-required-headers` - SDK automatically adds required headers (publishable API key for store, auth for admin) - regular fetch() missing these headers causes errors +- `sdk-no-json-stringify` - **NEVER use JSON.stringify() on body** - SDK handles serialization automatically +- `sdk-plain-objects` - Pass plain JavaScript objects to body, not strings +- `sdk-locate-first` - Always locate where SDK is instantiated in the project before using it + +### 2. React Query Patterns (HIGH) + +- `query-use-query` - Use `useQuery` for GET requests (data fetching) +- `query-use-mutation` - Use `useMutation` for POST/DELETE requests (mutations) +- `query-invalidate` - Invalidate queries in `onSuccess` to refresh data after mutations +- `query-keys-hierarchical` - Structure query keys hierarchically for effective cache management +- `query-loading-states` - Always handle `isLoading`, `isPending`, `isError` states + +### 3. Data Display (HIGH) + +- `display-price-format` - **CRITICAL**: Prices from Medusa are stored as-is ($49.99 = 49.99, NOT in cents). Display them directly - NEVER divide by 100 + +### 4. Error Handling (MEDIUM) + +- `error-on-error` - Implement `onError` callback in mutations to handle failures +- `error-display` - Show error messages to users when mutations fail +- `error-rollback` - Use optimistic updates with rollback on error for better UX + +## Critical SDK Pattern + +**ALWAYS pass plain objects to the SDK - NEVER use JSON.stringify():** + +```typescript +// ✅ CORRECT - Plain object +await sdk.client.fetch("/store/reviews", { + method: "POST", + body: { + product_id: "prod_123", + rating: 5, + } +}) + +// ❌ WRONG - JSON.stringify breaks the request +await sdk.client.fetch("/store/reviews", { + method: "POST", + body: JSON.stringify({ // ❌ DON'T DO THIS! + product_id: "prod_123", + rating: 5, + }) +}) +``` + +**Why this matters:** +- The SDK handles JSON serialization automatically +- Using JSON.stringify() will double-serialize and break the request +- The server won't be able to parse the body + +## Common Mistakes Checklist + +Before implementing, verify you're NOT doing these: + +**SDK Usage:** +- [ ] Using regular fetch() instead of the Medusa JS SDK (causes missing header errors) +- [ ] Not using existing SDK methods for built-in endpoints (e.g., using sdk.client.fetch("/store/products") instead of sdk.store.product.list()) +- [ ] Using JSON.stringify() on the body parameter +- [ ] Manually setting Content-Type headers (SDK adds them) +- [ ] Hardcoding SDK import paths (locate in project first) +- [ ] Not using sdk.client.fetch() for custom routes + +**React Query:** +- [ ] Not invalidating queries after mutations +- [ ] Using flat query keys instead of hierarchical +- [ ] Not handling loading and error states +- [ ] Forgetting to disable buttons during mutations (isPending) + +**Data Display:** +- [ ] **CRITICAL**: Dividing prices by 100 when displaying (prices are stored as-is: $49.99 = 49.99, NOT in cents) + +**Error Handling:** +- [ ] Not implementing onError callbacks +- [ ] Not showing error messages to users +- [ ] Not handling network failures gracefully + +## How to Use + +**For detailed patterns and examples, load reference file:** + +``` +references/frontend-integration.md - SDK usage, React Query patterns, API integration +``` + +The reference file contains: +- Step-by-step SDK integration patterns +- Complete React Query examples +- Correct vs incorrect code examples +- Query key best practices +- Optimistic update patterns +- Error handling strategies + +## When to Use MedusaDocs MCP Server + +**Use this skill for (PRIMARY SOURCE):** +- How to call custom API routes from storefront +- SDK usage patterns (sdk.client.fetch) +- React Query integration patterns +- Common mistakes and anti-patterns + +**Use MedusaDocs MCP server for (SECONDARY SOURCE):** +- Built-in SDK methods (sdk.admin.*, sdk.store.*) +- Official Medusa SDK API reference +- Framework-specific configuration options + +**Why skills come first:** +- Skills contain critical patterns like "don't use JSON.stringify" that MCP doesn't emphasize +- Skills show correct vs incorrect patterns; MCP shows what's possible +- Planning requires understanding patterns, not just API reference + +## Integration with Backend + +**⚠️ CRITICAL: ALWAYS use the Medusa JS SDK - NEVER use regular fetch()** + +When building features that span backend and frontend: + +1. **Backend (building-with-medusa skill):** Module → Workflow → API Route +2. **Storefront (this skill):** SDK → React Query → UI Components +3. **Connection:** + - Built-in endpoints: Use existing SDK methods (`sdk.store.product.list()`) + - Custom API routes: Use `sdk.client.fetch("/store/my-route")` + - **NEVER use regular fetch()** - missing publishable API key causes errors + +**Why the SDK is required:** +- Store routes need `x-publishable-api-key` header +- Admin routes need `Authorization` and session headers +- SDK handles all required headers automatically +- Regular fetch() without headers → authentication/authorization errors + +See `building-with-medusa` for backend API route patterns. diff --git a/.agents/skills/building-storefronts/references/frontend-integration.md b/.agents/skills/building-storefronts/references/frontend-integration.md new file mode 100644 index 0000000..d93292c --- /dev/null +++ b/.agents/skills/building-storefronts/references/frontend-integration.md @@ -0,0 +1,229 @@ +# Frontend SDK Integration + +## Contents +- [Frontend SDK Pattern](#frontend-sdk-pattern) + - [Locating the SDK](#locating-the-sdk) + - [Using sdk.client.fetch()](#using-sdkclientfetch) +- [React Query Pattern](#react-query-pattern) +- [Query Key Best Practices](#query-key-best-practices) +- [Error Handling](#error-handling) +- [Optimistic Updates](#optimistic-updates) + +This guide covers how to integrate Medusa custom API routes with frontend applications using the Medusa SDK and React Query. + +**Note:** API routes are also referred to as "endpoints" - these terms are interchangeable. + +## Frontend SDK Pattern + +### Locating the SDK + +**IMPORTANT:** Never hardcode SDK import paths. Always locate where the SDK is instantiated in the project first. + +Look for `@medusajs/js-sdk` + +The SDK instance is typically exported as `sdk`: + +```typescript +import { sdk } from "[LOCATE IN PROJECT]" +``` + +### Using sdk.client.fetch() + +**⚠️ CRITICAL: ALWAYS use the Medusa JS SDK for ALL API requests - NEVER use regular fetch()** + +**Why this is critical:** +- **Store API routes** require the publishable API key in headers +- **Admin API routes** require authentication headers +- **Regular fetch()** without these headers will cause errors +- The SDK automatically handles all required headers for you + +**When to use what:** +- **Existing endpoints** (built-in Medusa routes): Use existing SDK methods like `sdk.store.product.list()`, `sdk.admin.order.retrieve()` +- **Custom endpoints** (your custom API routes): Use `sdk.client.fetch()` for custom routes + +**⚠️ CRITICAL: The SDK handles JSON serialization automatically. NEVER use JSON.stringify() on the body.** + +Call custom API routes using the SDK: + +```typescript +import { sdk } from "[LOCATE SDK INSTANCE IN PROJECT]" + +// ✅ CORRECT - Pass object directly +const result = await sdk.client.fetch("/store/my-route", { + method: "POST", + body: { + email: "user@example.com", + name: "John Doe", + }, +}) + +// ❌ WRONG - Don't use JSON.stringify +const result = await sdk.client.fetch("/store/my-route", { + method: "POST", + body: JSON.stringify({ // ❌ DON'T DO THIS! + email: "user@example.com", + }), +}) +``` + +**Key points:** + +- **The SDK handles JSON serialization automatically** - just pass plain objects +- **NEVER use JSON.stringify()** - this will break the request +- No need to set Content-Type headers - SDK adds them +- Session/JWT authentication is handled automatically +- Publishable API key is automatically added + +### Built-in Endpoints vs Custom Endpoints + +**⚠️ CRITICAL: Use the appropriate SDK method based on endpoint type** + +```typescript +import { sdk } from "[LOCATE SDK INSTANCE IN PROJECT]" + +// ✅ CORRECT - Built-in endpoint: Use existing SDK method +const products = await sdk.store.product.list({ + limit: 10, + offset: 0 +}) + +// ✅ CORRECT - Custom endpoint: Use sdk.client.fetch() +const reviews = await sdk.client.fetch("/store/products/prod_123/reviews") + +// ❌ WRONG - Using regular fetch for ANY endpoint +const products = await fetch("http://localhost:9000/store/products") +// ❌ Error: Missing publishable API key header! + +// ❌ WRONG - Using regular fetch for custom endpoint +const reviews = await fetch("http://localhost:9000/store/products/prod_123/reviews") +// ❌ Error: Missing publishable API key header! + +// ❌ WRONG - Using sdk.client.fetch() for built-in endpoint when SDK method exists +const products = await sdk.client.fetch("/store/products") +// ❌ Less type-safe than using sdk.store.product.list() +``` + +**Why this matters:** +- **Store routes** require `x-publishable-api-key` header - SDK adds it automatically +- **Admin routes** require `Authorization` and session cookie headers - SDK adds them automatically +- **Regular fetch()** doesn't include these headers → API returns authentication/authorization errors +- Using existing SDK methods provides **better type safety** and autocomplete + +## React Query Pattern + +Use `useQuery` for GET requests and `useMutation` for POST/DELETE: + +```typescript +import { sdk } from "[LOCATE SDK INSTANCE IN PROJECT]" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" + +function MyComponent({ userId }: { userId: string }) { + const queryClient = useQueryClient() + + // GET request - fetching data + const { data, isLoading } = useQuery({ + queryKey: ["my-data", userId], + queryFn: () => sdk.client.fetch(`/store/my-route?userId=${userId}`), + enabled: !!userId, + }) + + // POST request - mutation with cache invalidation + const mutation = useMutation({ + mutationFn: (input: { email: string }) => + sdk.client.fetch("/store/my-route", { method: "POST", body: input }), + onSuccess: () => { + // Invalidate and refetch related queries + queryClient.invalidateQueries({ queryKey: ["my-data"] }) + }, + }) + + if (isLoading) return

Loading...

+ + return ( +
+

{data?.title}

+ + {mutation.isError &&

Error occurred

} +
+ ) +} +``` + +**Key states:** `isLoading`, `isPending`, `isSuccess`, `isError`, `error` + +## Query Key Best Practices + +Structure query keys for effective cache management: + +```typescript +// Good: Hierarchical structure +queryKey: ["products", productId] +queryKey: ["products", "list", { page, filters }] + +// Invalidate all product queries +queryClient.invalidateQueries({ queryKey: ["products"] }) + +// Invalidate specific product +queryClient.invalidateQueries({ queryKey: ["products", productId] }) +``` + +## Error Handling + +Handle API errors gracefully: + +```typescript +const mutation = useMutation({ + mutationFn: (input) => sdk.client.fetch("/store/my-route", { + method: "POST", + body: input + }), + onError: (error) => { + console.error("Mutation failed:", error) + // Show error message to user + }, +}) + +// In component +{mutation.isError && ( +

+ {mutation.error?.message || "An error occurred"} +

+)} +``` + +## Optimistic Updates + +Update UI immediately before server confirms: + +```typescript +const mutation = useMutation({ + mutationFn: (newItem) => + sdk.client.fetch("/store/items", { method: "POST", body: newItem }), + onMutate: async (newItem) => { + // Cancel outgoing refetches + await queryClient.cancelQueries({ queryKey: ["items"] }) + + // Snapshot previous value + const previousItems = queryClient.getQueryData(["items"]) + + // Optimistically update + queryClient.setQueryData(["items"], (old) => [...old, newItem]) + + // Return context with snapshot + return { previousItems } + }, + onError: (err, newItem, context) => { + // Rollback on error + queryClient.setQueryData(["items"], context.previousItems) + }, + onSettled: () => { + // Refetch after mutation + queryClient.invalidateQueries({ queryKey: ["items"] }) + }, +}) +``` diff --git a/.agents/skills/building-with-medusa/SKILL.md b/.agents/skills/building-with-medusa/SKILL.md new file mode 100644 index 0000000..b9f99d1 --- /dev/null +++ b/.agents/skills/building-with-medusa/SKILL.md @@ -0,0 +1,376 @@ +--- +name: building-with-medusa +description: Load automatically when planning, researching, or implementing ANY Medusa backend features (custom modules, API routes, workflows, data models, module links, business logic). REQUIRED for all Medusa backend work in ALL modes (planning, implementation, exploration). Contains architectural patterns, best practices, and critical rules that MCP servers don't provide. +--- + +# Medusa Backend Development + +Comprehensive backend development guide for Medusa applications. Contains patterns across 6 categories covering architecture, type safety, business logic placement, and common pitfalls. + +## When to Apply + +**Load this skill for ANY backend development task, including:** +- Creating or modifying custom modules and data models +- Implementing workflows for mutations +- Building API routes (store or admin) +- Defining module links between entities +- Writing business logic or validation +- Querying data across modules +- Implementing authentication/authorization + +**Also load these skills when:** +- **building-admin-dashboard-customizations:** Building admin UI (widgets, pages, forms) +- **building-storefronts:** Calling backend API routes from storefronts (SDK integration) + +## CRITICAL: Load Reference Files When Needed + +**The quick reference below is NOT sufficient for implementation.** You MUST load relevant reference files before writing code for that component. + +**Load these references based on what you're implementing:** + +- **Creating a module?** → MUST load `reference/custom-modules.md` first +- **Creating workflows?** → MUST load `reference/workflows.md` first +- **Creating API routes?** → MUST load `reference/api-routes.md` first +- **Creating module links?** → MUST load `reference/module-links.md` first +- **Querying data?** → MUST load `reference/querying-data.md` first +- **Adding authentication?** → MUST load `reference/authentication.md` first + +**Minimum requirement:** Load at least 1-2 reference files relevant to your specific task before implementing. + +## Critical Architecture Pattern + +**ALWAYS follow this flow - never bypass layers:** + +``` +Module (data models + CRUD operations) + ↓ used by +Workflow (business logic + mutations with rollback) + ↓ executed by +API Route (HTTP interface, validation middleware) + ↓ called by +Frontend (admin dashboard/storefront via SDK) +``` + +**Key conventions:** +- Only GET, POST, DELETE methods (never PUT/PATCH) +- Workflows are required for ALL mutations +- Business logic belongs in workflow steps, NOT routes +- Query with `query.graph()` for cross-module data retrieval +- Query with `query.index()` (Index Module) for filtering across separate modules with links +- Module links maintain isolation between modules + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | Architecture Violations | CRITICAL | `arch-` | +| 2 | Type Safety | CRITICAL | `type-` | +| 3 | Business Logic Placement | HIGH | `logic-` | +| 4 | Import & Code Organization | HIGH | `import-` | +| 5 | Data Access Patterns | MEDIUM (includes CRITICAL price rule) | `data-` | +| 6 | File Organization | MEDIUM | `file-` | + +## Quick Reference + +### 1. Architecture Violations (CRITICAL) + +- `arch-workflow-required` - Use workflows for ALL mutations, never call module services from routes +- `arch-layer-bypass` - Never bypass layers (route → service without workflow) +- `arch-http-methods` - Use only GET, POST, DELETE (never PUT/PATCH) +- `arch-module-isolation` - Use module links, not direct cross-module service calls +- `arch-query-config-fields` - Don't set explicit `fields` when using `req.queryConfig` + +### 2. Type Safety (CRITICAL) + +- `type-request-schema` - Pass Zod inferred type to `MedusaRequest` when using `req.validatedBody` +- `type-authenticated-request` - Use `AuthenticatedMedusaRequest` for protected routes (not `MedusaRequest`) +- `type-export-schema` - Export both Zod schema AND inferred type from middlewares +- `type-linkable-auto` - Never add `.linkable()` to data models (automatically added) +- `type-module-name-camelcase` - Module names MUST be camelCase, never use dashes (causes runtime errors) + +### 3. Business Logic Placement (HIGH) + +- `logic-workflow-validation` - Put business validation in workflow steps, not API routes +- `logic-ownership-checks` - Validate ownership/permissions in workflows, not routes +- `logic-module-service` - Keep modules simple (CRUD only), put logic in workflows + +### 4. Import & Code Organization (HIGH) + +- `import-top-level` - Import workflows/modules at file top, never use `await import()` in route body +- `import-static-only` - Use static imports for all dependencies +- `import-no-dynamic-routes` - Dynamic imports add overhead and break type checking + +### 5. Data Access Patterns (MEDIUM) + +- `data-price-format` - **CRITICAL**: Prices are stored as-is in Medusa (49.99 stored as 49.99, NOT in cents). Never multiply by 100 when saving or divide by 100 when displaying +- `data-query-method` - Use `query.graph()` for retrieving data; use `query.index()` (Index Module) for filtering across linked modules +- `data-query-graph` - Use `query.graph()` for cross-module queries with dot notation (without cross-module filtering) +- `data-query-index` - Use `query.index()` when filtering by properties of linked data models in separate modules +- `data-list-and-count` - Use `listAndCount` for single-module paginated queries +- `data-linked-filtering` - `query.graph()` can't filter by linked module fields - use `query.index()` or query from that entity directly +- `data-no-js-filter` - Don't use JavaScript `.filter()` on linked data - use database filters (`query.index()` or query the entity) +- `data-same-module-ok` - Can filter by same-module relations with `query.graph()` (e.g., product.variants) +- `data-auth-middleware` - Trust `authenticate` middleware, don't manually check `req.auth_context` + +### 6. File Organization (MEDIUM) + +- `file-workflow-steps` - Recommended: Create steps in `src/workflows/steps/[name].ts` +- `file-workflow-composition` - Composition functions in `src/workflows/[name].ts` +- `file-middleware-exports` - Export schemas and types from middleware files +- `file-links-directory` - Define module links in `src/links/[name].ts` + +## Workflow Composition Rules + +**The workflow function has critical constraints:** + +```typescript +// ✅ CORRECT +const myWorkflow = createWorkflow( + "name", + function (input) { // Regular function, not async, not arrow + const result = myStep(input) // No await + return new WorkflowResponse(result) + } +) + +// ❌ WRONG +const myWorkflow = createWorkflow( + "name", + async (input) => { // ❌ No async, no arrow functions + const result = await myStep(input) // ❌ No await + if (input.condition) { /* ... */ } // ❌ No conditionals + return new WorkflowResponse(result) + } +) +``` + +**Constraints:** +- No async/await (runs at load time) +- No arrow functions (use `function`) +- No conditionals/ternaries (use `when()`) +- No variable manipulation (use `transform()`) +- No date creation (use `transform()`) +- Multiple step calls need `.config({ name: "unique-name" })` to avoid conflicts + +## Common Mistakes Checklist + +Before implementing, verify you're NOT doing these: + +**Architecture:** +- [ ] Calling module services directly from API routes +- [ ] Using PUT or PATCH methods +- [ ] Bypassing workflows for mutations +- [ ] Setting `fields` explicitly with `req.queryConfig` +- [ ] Skipping migrations after creating module links + +**Type Safety:** +- [ ] Forgetting `MedusaRequest` type argument +- [ ] Using `MedusaRequest` instead of `AuthenticatedMedusaRequest` for protected routes +- [ ] Not exporting Zod inferred type from middlewares +- [ ] Adding `.linkable()` to data models +- [ ] Using dashes in module names (must be camelCase) + +**Business Logic:** +- [ ] Validating business rules in API routes +- [ ] Checking ownership in routes instead of workflows +- [ ] Manually checking `req.auth_context?.actor_id` when middleware already applied + +**Imports:** +- [ ] Using `await import()` in route handler bodies +- [ ] Dynamic imports for workflows or modules + +**Data Access:** +- [ ] **CRITICAL**: Multiplying prices by 100 when saving or dividing by 100 when displaying (prices are stored as-is: $49.99 = 49.99) +- [ ] Filtering by linked module fields with `query.graph()` (use `query.index()` or query from other side instead) +- [ ] Using JavaScript `.filter()` on linked data (use `query.index()` or query the linked entity directly) +- [ ] Not using `query.graph()` for cross-module data retrieval +- [ ] Using `query.graph()` when you need to filter across separate modules (use `query.index()` instead) + +## Validating Implementation + +**CRITICAL: Always run the build command after completing implementation to catch type errors and runtime issues.** + +### When to Validate +- After implementing any new feature +- After making changes to modules, workflows, or API routes +- Before marking tasks as complete +- Proactively, without waiting for the user to ask + +### How to Run Build + +Detect the package manager and run the appropriate command: + +```bash +npm run build # or pnpm build / yarn build +``` + +### Handling Build Errors + +If the build fails: +1. Read the error messages carefully +2. Fix type errors, import issues, and syntax errors +3. Run the build again to verify the fix +4. Do NOT mark implementation as complete until build succeeds + +**Common build errors:** +- Missing imports or exports +- Type mismatches (e.g., missing `MedusaRequest` type argument) +- Incorrect workflow composition (async functions, conditionals) + +## Next Steps - Testing Your Implementation + +**After successfully implementing a feature, always provide these next steps to the user:** + +### 1. Start the Development Server + +If the server isn't already running, start it: + +```bash +npm run dev # or pnpm dev / yarn dev +``` + +### 2. Access the Admin Dashboard + +Open your browser and navigate to: +- **Admin Dashboard:** http://localhost:9000/app + +Log in with your admin credentials to test any admin-related features. + +### 3. Test API Routes + +If you implemented custom API routes, list them for the user to test: + +**Admin Routes (require authentication):** +- `POST http://localhost:9000/admin/[your-route]` - Description of what it does +- `GET http://localhost:9000/admin/[your-route]` - Description of what it does + +**Store Routes (public or customer-authenticated):** +- `POST http://localhost:9000/store/[your-route]` - Description of what it does +- `GET http://localhost:9000/store/[your-route]` - Description of what it does + +**Testing with cURL example:** +```bash +# Admin route (requires authentication) +curl -X POST http://localhost:9000/admin/reviews/123/approve \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + --cookie "connect.sid=YOUR_SESSION_COOKIE" + +# Store route +curl -X POST http://localhost:9000/store/reviews \ + -H "Content-Type: application/json" \ + -d '{"product_id": "prod_123", "rating": 5, "comment": "Great product!"}' +``` + +### 4. Additional Testing Steps + +Depending on what was implemented, mention: +- **Workflows:** Test mutation operations and verify rollback on errors +- **Subscribers:** Trigger events and check logs for subscriber execution +- **Scheduled jobs:** Wait for job execution or check logs for cron output + +### Format for Presenting Next Steps + +Always present next steps in a clear, actionable format after implementation: + +```markdown +## Implementation Complete + +The [feature name] has been successfully implemented. Here's how to test it: + +### Start the Development Server +[server start command based on package manager] + +### Access the Admin Dashboard +Open http://localhost:9000/app in your browser + +### Test the API Routes +I've added the following routes: + +**Admin Routes:** +- POST /admin/[route] - [description] +- GET /admin/[route] - [description] + +**Store Routes:** +- POST /store/[route] - [description] + +### What to Test +1. [Specific test case 1] +2. [Specific test case 2] +3. [Specific test case 3] +``` + +## How to Use + +**For detailed patterns and examples, load reference files:** + +``` +reference/custom-modules.md - Creating modules with data models +reference/workflows.md - Workflow creation and step patterns +reference/api-routes.md - API route structure and validation +reference/module-links.md - Linking entities across modules +reference/querying-data.md - Query patterns and filtering rules +reference/authentication.md - Protecting routes and accessing users +reference/error-handling.md - MedusaError types and patterns +reference/scheduled-jobs.md - Cron jobs and periodic tasks +reference/subscribers-and-events.md - Event handling +reference/troubleshooting.md - Common errors and solutions +``` + +Each reference file contains: +- Step-by-step implementation checklists +- Correct vs incorrect code examples +- TypeScript patterns and type safety +- Common pitfalls and solutions + +## When to Use This Skill vs MedusaDocs MCP Server + +**⚠️ CRITICAL: This skill should be consulted FIRST for planning and implementation.** + +**Use this skill for (PRIMARY SOURCE):** +- **Planning** - Understanding how to structure Medusa backend features +- **Architecture** - Module → Workflow → API Route patterns +- **Best practices** - Correct vs incorrect code patterns +- **Critical rules** - What NOT to do (common mistakes and anti-patterns) +- **Implementation patterns** - Step-by-step guides with checklists + +**Use MedusaDocs MCP server for (SECONDARY SOURCE):** +- Specific method signatures after you know which method to use +- Built-in module configuration options +- Official type definitions +- Framework-level configuration details + +**Why skills come first:** +- Skills contain opinionated guidance and anti-patterns MCP doesn't have +- Skills show architectural patterns needed for planning +- MCP is reference material; skills are prescriptive guidance + +## Integration with Frontend Applications + +**⚠️ CRITICAL: Frontend applications MUST use the Medusa JS SDK for ALL API requests** + +When building features that span backend and frontend: + +**For Admin Dashboard:** +1. **Backend (this skill):** Module → Workflow → API Route +2. **Frontend:** Load `building-admin-dashboard-customizations` skill +3. **Connection:** + - Built-in endpoints: Use existing SDK methods (`sdk.admin.product.list()`) + - Custom API routes: Use `sdk.client.fetch("/admin/my-route")` + - **NEVER use regular fetch()** - missing auth headers will cause errors + +**For Storefronts:** +1. **Backend (this skill):** Module → Workflow → API Route +2. **Frontend:** Load `building-storefronts` skill +3. **Connection:** + - Built-in endpoints: Use existing SDK methods (`sdk.store.product.list()`) + - Custom API routes: Use `sdk.client.fetch("/store/my-route")` + - **NEVER use regular fetch()** - missing publishable API key will cause errors + +**Why the SDK is required:** +- Store routes need `x-publishable-api-key` header +- Admin routes need `Authorization` and session headers +- SDK handles all required headers automatically +- Regular fetch() without headers → authentication/authorization errors + +See respective frontend skills for complete integration patterns. diff --git a/.agents/skills/building-with-medusa/reference/api-routes.md b/.agents/skills/building-with-medusa/reference/api-routes.md new file mode 100644 index 0000000..437305e --- /dev/null +++ b/.agents/skills/building-with-medusa/reference/api-routes.md @@ -0,0 +1,873 @@ +# Custom API Routes + +API routes (also called "endpoints") are the primary way to expose custom functionality to storefronts and admin dashboards. + +## Contents +- [Path Conventions](#path-conventions) +- [Middleware Validation](#middleware-validation) +- [Query Parameter Validation](#query-parameter-validation) +- [Request Query Config for List Endpoints](#request-query-config-for-list-endpoints) +- [API Route Structure](#api-route-structure) +- [Error Handling](#error-handling) +- [Protected Routes](#protected-routes) +- [Using Workflows in API Routes](#using-workflows-in-api-routes) + +## Path Conventions + +### Store API Routes (Storefront) +- **Path prefix**: `/store/` +- **Examples**: `/store/newsletter-signup`, `/store/custom-search` +- **Authentication**: SDK automatically includes publishable API key + +### Admin API Routes (Dashboard) +- **Path prefix**: `/admin/` +- **Examples**: `/admin/custom-reports`, `/admin/bulk-operations` +- **Authentication**: SDK automatically includes auth headers (bearer/session) + +**Detailed authentication patterns**: See [authentication.md](authentication.md) + +## Middleware Validation + +**⚠️ CRITICAL**: Always validate request bodies using Zod schemas and the `validateAndTransformBody` middleware. + +### Combining Multiple Middlewares + +When you need both authentication AND validation, pass them as an array. **NEVER nest validation inside authenticate:** + +```typescript +// ✅ CORRECT - Multiple middlewares in array +export default defineMiddlewares({ + routes: [ + { + matcher: "/store/products/:id/reviews", + method: "POST", + middlewares: [ + authenticate("customer", ["session", "bearer"]), + validateAndTransformBody(CreateReviewSchema) + ], + }, + ], +}) + +// ❌ WRONG - Don't nest validator inside authenticate +export default defineMiddlewares({ + routes: [ + { + matcher: "/store/products/:id/reviews", + method: "POST", + middlewares: [authenticate("customer", ["session", "bearer"], { + validator: CreateReviewSchema // This doesn't work! + })], + }, + ], +}) +``` + +**Middleware order matters:** Put `authenticate` before `validateAndTransformBody` so authentication happens first. + +### Step 1: Create Middleware File + +```typescript +// api/store/[feature]/middlewares.ts +import { MiddlewareRoute, validateAndTransformBody } from "@medusajs/framework" +import { z } from "zod" + +export const CreateMySchema = z.object({ + email: z.string().email(), + name: z.string().min(2), + // other fields +}) + +// Export the inferred type for use in route handlers +export type CreateMySchema = z.infer + +export const myMiddlewares: MiddlewareRoute[] = [ + { + matcher: "/store/my-route", + method: "POST", + middlewares: [validateAndTransformBody(CreateMySchema)], + }, +] +``` + +### Step 2: Register in api/middlewares.ts + +```typescript +// api/middlewares.ts +import { defineMiddlewares } from "@medusajs/framework/http" +import { myMiddlewares } from "./store/[feature]/middlewares" + +export default defineMiddlewares({ + routes: [...myMiddlewares], +}) +``` + +**⚠️ CRITICAL - Middleware Export Pattern:** + +Middlewares are exported as **named arrays**, NOT default exports with config objects: + +```typescript +// ✅ CORRECT - Named export of MiddlewareRoute array +// api/store/reviews/middlewares.ts +export const reviewMiddlewares: MiddlewareRoute[] = [ + { + matcher: "/store/reviews", + method: "POST", + middlewares: [validateAndTransformBody(CreateReviewSchema)], + }, +] + +// ✅ CORRECT - Import and spread the named array +// api/middlewares.ts +import { reviewMiddlewares } from "./store/reviews/middlewares" + +export default defineMiddlewares({ + routes: [...reviewMiddlewares], +}) +``` + +```typescript +// ❌ WRONG - Don't use default export with .config +// api/store/reviews/middlewares.ts +export default { + config: { + routes: [...], // This is NOT the middleware pattern! + }, +} + +// ❌ WRONG - Don't access .config.routes +// api/middlewares.ts +import reviewMiddlewares from "./store/reviews/middlewares" +export default defineMiddlewares({ + routes: [...reviewMiddlewares.config.routes], // This doesn't work! +}) +``` + +**Why this matters:** +- Middleware files export arrays directly, not config objects +- Route files (like `route.ts`) use `export const config = defineRouteConfig(...)` +- Don't confuse the two patterns - middlewares are simpler (just an array) + +### Step 3: Use Typed req.validatedBody in Route + +**⚠️ CRITICAL**: When using `req.validatedBody`, you MUST pass the inferred Zod schema type as a type argument to `MedusaRequest`. Otherwise, you'll get TypeScript errors when accessing `req.validatedBody`. + +```typescript +// api/store/my-route/route.ts +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { CreateMySchema } from "./middlewares" + +// ✅ CORRECT: Pass the Zod schema type as type argument +export async function POST( + req: MedusaRequest, + res: MedusaResponse +) { + // Now req.validatedBody is properly typed + const { email, name } = req.validatedBody + + // ... rest of implementation +} + +// ❌ WRONG: Without type argument, req.validatedBody will have type errors +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const { email, name } = req.validatedBody // Type error! +} +``` + +## Query Parameter Validation + +For API routes that accept query parameters, use the `validateAndTransformQuery` middleware to validate them. + +**⚠️ IMPORTANT**: When using `validateAndTransformQuery`, access query parameters via `req.validatedQuery` instead of `req.query`. + +### Step 1: Create Validation Schema + +Create a Zod schema for the query parameters. Since query parameters are originally strings or arrays of strings, use `z.preprocess` to transform them to other types: + +```typescript +// api/custom/validators.ts +import { z } from "zod" + +export const GetMyRouteSchema = z.object({ + cart_id: z.string(), // String parameters don't need preprocessing + limit: z.preprocess( + (val) => { + if (val && typeof val === "string") { + return parseInt(val) + } + return val + }, + z.number().optional() + ), + status: z.enum(["active", "pending", "completed"]).optional(), +}) +``` + +### Step 2: Add Middleware + +```typescript +// api/middlewares.ts +import { + validateAndTransformQuery, + defineMiddlewares, +} from "@medusajs/framework/http" +import { GetMyRouteSchema } from "./custom/validators" + +export default defineMiddlewares({ + routes: [ + { + matcher: "/store/my-route", + method: "GET", + middlewares: [ + validateAndTransformQuery(GetMyRouteSchema, {}), + ], + }, + ], +}) +``` + +### Step 3: Use Validated Query in Route + +```typescript +// api/store/my-route/route.ts +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" + +export async function GET(req: MedusaRequest, res: MedusaResponse) { + // Access validated query parameters (not req.query!) + const { cart_id, limit, status } = req.validatedQuery + + // cart_id is string, limit is number, status is enum + const query = req.scope.resolve("query") + + const { data } = await query.graph({ + entity: "my_entity", + fields: ["id", "name"], + filters: { cart_id, status }, + }) + + return res.json({ items: data }) +} +``` + +## Request Query Config for List Endpoints + +**⚠️ BEST PRACTICE**: For API routes that retrieve lists of resources, use request query config to allow clients to control fields, pagination, and ordering. + +This pattern: +- Allows clients to specify which fields/relations to retrieve +- Enables client-controlled pagination +- Supports custom ordering +- Provides sensible defaults + +### Step 1: Add Middleware with createFindParams + +```typescript +// api/middlewares.ts +import { + validateAndTransformQuery, + defineMiddlewares, +} from "@medusajs/framework/http" +import { createFindParams } from "@medusajs/medusa/api/utils/validators" + +// createFindParams() generates a schema that accepts: +// - fields: Select specific fields/relations +// - offset: Skip N items +// - limit: Max items to return +// - order: Order by field(s) ASC/DESC +export const GetProductsSchema = createFindParams() + +export default defineMiddlewares({ + routes: [ + { + matcher: "/store/products", + method: "GET", + middlewares: [ + validateAndTransformQuery( + GetProductsSchema, + { + defaults: [ + "id", + "title", + "variants.*", // Include all variant fields by default + ], + isList: true, // Indicates this returns a list + defaultLimit: 15, // Default pagination limit + } + ), + ], + }, + ], +}) +``` + +**Configuration Options:** +- `defaults`: Array of default fields and relations to retrieve +- `isList`: Boolean indicating if this returns a list (affects pagination) +- `allowed`: (Optional) Array of fields/relations allowed in the `fields` query param +- `defaultLimit`: (Optional) Default limit if not provided (default: 50) + +### Step 2: Use Query Config in Route + +**⚠️ CRITICAL**: When using `req.queryConfig`, do NOT explicitly set the `fields` property in your query. The `queryConfig` already contains the fields configuration, and setting it explicitly will cause TypeScript errors. + +```typescript +// api/store/products/route.ts +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" + +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const query = req.scope.resolve("query") + + // ✅ CORRECT: Only use ...req.queryConfig (includes fields, pagination, etc.) + const { data: products } = await query.graph({ + entity: "product", + ...req.queryConfig, // Contains fields, select, limit, offset, order + }) + + return res.json({ products }) +} + +// ❌ WRONG: Don't set fields explicitly when using queryConfig +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const query = req.scope.resolve("query") + + const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "title"], // ❌ Type error! queryConfig already sets fields + ...req.queryConfig, + }) + + return res.json({ products }) +} +``` + +**If you need additional filters**, only add those - not fields: + +```typescript +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const query = req.scope.resolve("query") + const { id } = req.params + + // ✅ CORRECT: Add filters while using queryConfig + const { data: products } = await query.graph({ + entity: "product", + filters: { id }, // Additional filters are OK + ...req.queryConfig, // Fields come from here + }) + + return res.json({ products }) +} +``` + +### Step 3: Client Usage Examples + +Clients can now control the API response: + +```typescript +// Default response (uses middleware defaults) +GET /store/products +// Returns: id, title, variants.* + +// Custom fields selection +GET /store/products?fields=id,title,description +// Returns: only id, title, description + +// Pagination +GET /store/products?limit=10&offset=20 +// Returns: 10 items, skipping first 20 + +// Ordering +GET /store/products?order=title +// Returns: products ordered by title ascending + +GET /store/products?order=-created_at +// Returns: products ordered by created_at descending (- prefix) + +// Combined +GET /store/products?fields=id,title,brand.*&limit=5&order=-created_at +// Returns: 5 items with custom fields, newest first +``` + +### Advanced: Custom Query Param + Query Config + +You can combine custom query parameters with query config: + +```typescript +// validators.ts +import { z } from "zod" +import { createFindParams } from "@medusajs/medusa/api/utils/validators" + +export const GetProductsSchema = createFindParams().merge( + z.object({ + category_id: z.string().optional(), + in_stock: z.preprocess( + (val) => val === "true", + z.boolean().optional() + ), + }) +) +``` + +```typescript +// route.ts +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const query = req.scope.resolve("query") + const { category_id, in_stock } = req.validatedQuery + + const filters: any = {} + if (category_id) filters.category_id = category_id + if (in_stock !== undefined) filters.in_stock = in_stock + + const { data: products } = await query.graph({ + entity: "product", + filters, + ...req.queryConfig, // Still get fields, pagination, order + }) + + return res.json({ products }) +} +``` + +## Import Organization + +**⚠️ CRITICAL**: Always import workflows, modules, and other dependencies at the TOP of the file, never inside the route handler function body. + +### ✅ CORRECT - Imports at Top + +```typescript +// api/store/reviews/route.ts +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { createReviewWorkflow } from "../../../workflows/create-review" +import { CreateReviewSchema } from "./middlewares" + +export async function POST( + req: MedusaRequest, + res: MedusaResponse +) { + const { result } = await createReviewWorkflow(req.scope).run({ + input: req.validatedBody + }) + + return res.json({ review: result }) +} +``` + +### ❌ WRONG - Dynamic Imports in Route Body + +```typescript +// api/store/reviews/route.ts +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" + +export async function POST(req: MedusaRequest, res: MedusaResponse) { + // ❌ WRONG: Don't use dynamic imports in route handlers + const { createReviewWorkflow } = await import("../../../workflows/create-review") + + const { result } = await createReviewWorkflow(req.scope).run({ + input: req.validatedBody + }) + + return res.json({ review: result }) +} +``` + +**Why this matters:** +- Dynamic imports add unnecessary overhead to every request +- Makes code harder to read and maintain +- Breaks static analysis and TypeScript checking +- Can cause module resolution issues in production + +## API Route Structure + +**⚠️ IMPORTANT**: Medusa uses only GET, POST and DELETE as a convention. +- **GET** for reads +- **POST** for mutations (create/update) +- **DELETE** for deletions + +Don't use PUT or PATCH. + +### Basic API Route + +```typescript +// api/store/my-route/route.ts +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { MedusaError } from "@medusajs/framework/utils" + +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const query = req.scope.resolve("query") + + // Query data + const { data: items } = await query.graph({ + entity: "entity_name", + fields: ["id", "name"], + }) + + return res.status(200).json({ items }) +} + +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const { field } = req.validatedBody + + // Execute workflow (mutations should always use workflows) + const { result } = await myWorkflow(req.scope).run({ + input: { field }, + }) + + return res.status(200).json({ result }) +} +``` + +### Accessing Request Data + +```typescript +// Validated body (from middleware) +const { email, name } = req.validatedBody + +// Query parameters +const { page, limit } = req.query + +// Route parameters +const { id } = req.params + +// Resolve services +const query = req.scope.resolve("query") +const myService = req.scope.resolve("my-module") +``` + +## Error Handling + +Use `MedusaError` for consistent error responses: + +```typescript +import { MedusaError } from "@medusajs/framework/utils" + +// Not found +throw new MedusaError(MedusaError.Types.NOT_FOUND, "Resource not found") + +// Invalid data +throw new MedusaError(MedusaError.Types.INVALID_DATA, "Invalid input provided") + +// Unauthorized +throw new MedusaError(MedusaError.Types.UNAUTHORIZED, "Authentication required") + +// Conflict +throw new MedusaError(MedusaError.Types.CONFLICT, "Resource already exists") + +// Other types: INVALID_STATE, NOT_ALLOWED, DUPLICATE_ERROR +``` + +### Error Response Format + +Medusa automatically formats errors: + +```json +{ + "type": "not_found", + "message": "Resource not found" +} +``` + +## Protected Routes + +### Default Protected Routes + +All routes under these prefixes are automatically protected: +- `/admin/*` - Requires authenticated admin user +- `/store/customers/me/*` - Requires authenticated customer + +### Custom Protected Routes + +To protect routes under different prefixes, use the `authenticate` middleware: + +```typescript +// api/middlewares.ts +import { + defineMiddlewares, + authenticate, +} from "@medusajs/framework/http" + +export default defineMiddlewares({ + routes: [ + // Only allow authenticated admin users + { + matcher: "/custom/admin*", + middlewares: [authenticate("user", ["session", "bearer", "api-key"])], + }, + // Only allow authenticated customers + { + matcher: "/store/reviews*", + middlewares: [authenticate("customer", ["session", "bearer"])], + }, + ], +}) +``` + +### Accessing Authenticated User + +**⚠️ CRITICAL**: For routes protected with `authenticate` middleware, you MUST use `AuthenticatedMedusaRequest` instead of `MedusaRequest` to avoid type errors when accessing `req.auth_context.actor_id`. + +```typescript +import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http" + +// ✅ CORRECT - Use AuthenticatedMedusaRequest for protected routes +export async function POST( + req: AuthenticatedMedusaRequest, + res: MedusaResponse +) { + // For admin routes + const userId = req.auth_context.actor_id // Admin user ID + + // For customer routes + const customerId = req.auth_context.actor_id // Customer ID + + // Your logic here +} + +// ❌ WRONG - Don't use MedusaRequest for protected routes +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const userId = req.auth_context.actor_id // Type error! +} +``` + +**See [authentication.md](authentication.md) for complete authentication patterns.** + +## Using Workflows in API Routes + +**⚠️ BEST PRACTICE**: Workflows are the standard way to perform mutations (create, update, delete) in Medusa. API routes should execute workflows and return their response. + +### Example: Create Workflow + +```typescript +import { createCustomersWorkflow } from "@medusajs/medusa/core-flows" + +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const { email } = req.validatedBody + + const { result } = await createCustomersWorkflow(req.scope).run({ + input: { + customersData: [ + { + email, + has_account: false, + }, + ], + }, + }) + + return res.json({ customer: result[0] }) +} +``` + +### Example: Custom Workflow + +```typescript +import { myCustomWorkflow } from "../../workflows/my-workflow" + +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const { data } = req.validatedBody + + try { + const { result } = await myCustomWorkflow(req.scope).run({ + input: { data }, + }) + + return res.json({ result }) + } catch (error) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + error.message + ) + } +} +``` + +## Common Built-in Workflows + +Ask MedusaDocs for specific workflow names and their input parameters: +- Customer workflows: create, update, delete customers +- Product workflows: create, update, delete products +- Order workflows: create, cancel, fulfill orders +- Cart workflows: create, update, complete carts +- And many more... + +## API Route Organization + +Organize routes by feature or domain: + +``` +src/api/ +├── admin/ +│ ├── custom-reports/ +│ │ ├── route.ts +│ │ └── middlewares.ts +│ └── bulk-operations/ +│ ├── route.ts +│ └── middlewares.ts +└── store/ + ├── newsletter/ + │ ├── route.ts + │ └── middlewares.ts + └── reviews/ + ├── route.ts + ├── [id]/ + │ └── route.ts + └── middlewares.ts +``` + +## Common Patterns + +### Pattern: List with Query Config (Recommended) + +```typescript +// middlewares.ts +import { validateAndTransformQuery } from "@medusajs/framework/http" +import { createFindParams } from "@medusajs/medusa/api/utils/validators" + +export const GetMyEntitiesSchema = createFindParams() + +export default defineMiddlewares({ + routes: [ + { + matcher: "/store/my-entities", + method: "GET", + middlewares: [ + validateAndTransformQuery(GetMyEntitiesSchema, { + defaults: ["id", "name", "created_at"], + isList: true, + defaultLimit: 15, + }), + ], + }, + ], +}) + +// route.ts +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const query = req.scope.resolve("query") + + const { data, metadata } = await query.graph({ + entity: "my_entity", + ...req.queryConfig, // Handles fields, pagination automatically + }) + + return res.json({ + items: data, + count: metadata.count, + limit: req.queryConfig.pagination.take, + offset: req.queryConfig.pagination.skip, + }) +} +``` + +### Pattern: Retrieve Single Resource with Relations + +```typescript +// For single resource endpoints, you can still use query config +// middlewares.ts +export const GetMyEntitySchema = createFindParams() + +export default defineMiddlewares({ + routes: [ + { + matcher: "/store/my-entities/:id", + method: "GET", + middlewares: [ + validateAndTransformQuery(GetMyEntitySchema, { + defaults: ["id", "name", "variants.*", "brand.*"], + isList: false, // Single resource + }), + ], + }, + ], +}) + +// route.ts +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const query = req.scope.resolve("query") + const { id } = req.params + + const { data } = await query.graph({ + entity: "my_entity", + filters: { id }, + ...req.queryConfig, + }) + + if (!data || data.length === 0) { + throw new MedusaError(MedusaError.Types.NOT_FOUND, "Resource not found") + } + + return res.json({ item: data[0] }) +} +``` + +### Pattern: Search with Custom Filters + Query Config + +```typescript +// validators.ts +export const GetMyEntitiesSchema = createFindParams().merge( + z.object({ + q: z.string().optional(), // Search query + status: z.enum(["active", "pending", "completed"]).optional(), + }) +) + +// middlewares.ts +export default defineMiddlewares({ + routes: [ + { + matcher: "/store/my-entities", + method: "GET", + middlewares: [ + validateAndTransformQuery(GetMyEntitiesSchema, { + defaults: ["id", "name", "status"], + isList: true, + }), + ], + }, + ], +}) + +// route.ts +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const query = req.scope.resolve("query") + const { q, status } = req.validatedQuery + + const filters: any = {} + + if (q) { + filters.name = { $like: `%${q}%` } + } + + if (status) { + filters.status = status + } + + const { data } = await query.graph({ + entity: "my_entity", + filters, + ...req.queryConfig, // Client can still control fields, pagination + }) + + return res.json({ items: data }) +} +``` + +### Pattern: Manual Query (When Query Config Not Needed) + +For simple queries where you don't need client-controlled fields/pagination: + +```typescript +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const query = req.scope.resolve("query") + + const { data } = await query.graph({ + entity: "my_entity", + fields: ["id", "name"], + filters: { status: "active" }, + pagination: { + take: 10, + skip: 0, + }, + }) + + return res.json({ items: data }) +} +``` diff --git a/.agents/skills/building-with-medusa/reference/authentication.md b/.agents/skills/building-with-medusa/reference/authentication.md new file mode 100644 index 0000000..02860c9 --- /dev/null +++ b/.agents/skills/building-with-medusa/reference/authentication.md @@ -0,0 +1,556 @@ +# Authentication in Medusa + +Authentication in Medusa secures API routes and ensures only authorized users can access protected resources. + +## Contents +- [Default Protected Routes](#default-protected-routes) +- [Authentication Methods](#authentication-methods) +- [Custom Protected Routes](#custom-protected-routes) +- [Accessing Authenticated User](#accessing-authenticated-user) +- [Authentication Patterns](#authentication-patterns) + +## Default Protected Routes + +Medusa automatically protects certain route prefixes: + +### Admin Routes (`/admin/*`) +- **Who can access**: Authenticated admin users only +- **Authentication methods**: Session, Bearer token, API key +- **Example**: `/admin/products`, `/admin/custom-reports` + +### Customer Routes (`/store/customers/me/*`) +- **Who can access**: Authenticated customers only +- **Authentication methods**: Session, Bearer token +- **Example**: `/store/customers/me/orders`, `/store/customers/me/addresses` + +**These routes require no additional configuration** - authentication is handled automatically by Medusa. + +## Authentication Methods + +### Session Authentication +- Used after login via email/password +- Cookie-based session management +- Automatically handled by Medusa SDK + +### Bearer Token (JWT) +- Token-based authentication +- Passed in `Authorization: Bearer ` header +- Used by frontend applications + +### API Key +- Admin-only authentication method +- Used for server-to-server communication +- Passed in `x-medusa-access-token` header + +## Custom Protected Routes + +**⚠️ CRITICAL: Only add `authenticate` middleware to routes OUTSIDE the default prefixes.** + +Routes with these prefixes are automatically authenticated - **do NOT add middleware:** +- `/admin/*` - Already requires authenticated admin user +- `/store/customers/me/*` - Already requires authenticated customer + +```typescript +// ✅ CORRECT - Custom route needs authenticate middleware +export default defineMiddlewares({ + routes: [ + { + matcher: "/store/reviews*", // Not a default protected prefix + middlewares: [authenticate("customer", ["session", "bearer"])], + }, + ], +}) + +// ❌ WRONG - /admin routes are automatically authenticated +export default defineMiddlewares({ + routes: [ + { + matcher: "/admin/reports*", // Already protected! + middlewares: [authenticate("user", ["session", "bearer"])], // Redundant! + }, + ], +}) +``` + +To protect custom routes outside the default prefixes, use the `authenticate` middleware. + +### Protecting Custom Admin Routes + +```typescript +// api/middlewares.ts +import { + defineMiddlewares, + authenticate, +} from "@medusajs/framework/http" + +export default defineMiddlewares({ + routes: [ + { + matcher: "/custom/admin*", + middlewares: [ + authenticate("user", ["session", "bearer", "api-key"]) + ], + }, + ], +}) +``` + +**Parameters:** +- First parameter: `"user"` for admin users, `"customer"` for customers +- Second parameter: Array of allowed authentication methods + +### Protecting Custom Customer Routes + +```typescript +// api/middlewares.ts +import { + defineMiddlewares, + authenticate, +} from "@medusajs/framework/http" + +export default defineMiddlewares({ + routes: [ + { + matcher: "/store/reviews*", + middlewares: [ + authenticate("customer", ["session", "bearer"]) + ], + }, + ], +}) +``` + +### Multiple Protected Routes + +```typescript +// api/middlewares.ts +export default defineMiddlewares({ + routes: [ + // Protect custom admin routes + { + matcher: "/custom/admin*", + middlewares: [authenticate("user", ["session", "bearer", "api-key"])], + }, + // Protect custom customer routes + { + matcher: "/store/reviews*", + middlewares: [authenticate("customer", ["session", "bearer"])], + }, + // Protect wishlist routes + { + matcher: "/store/wishlists*", + middlewares: [authenticate("customer", ["session", "bearer"])], + }, + ], +}) +``` + +## Accessing Authenticated User + +Once a route is protected with the `authenticate` middleware, you can access the authenticated user's information via `req.auth_context`. + +**⚠️ CRITICAL - Type Safety**: For protected routes, you MUST use `AuthenticatedMedusaRequest` instead of `MedusaRequest` to avoid type errors when accessing `req.auth_context.actor_id`. + +**⚠️ CRITICAL - Manual Validation**: Do NOT manually validate authentication in your route handlers when using the `authenticate` middleware. The middleware already ensures the user is authenticated - manual checks are redundant and indicate a misunderstanding of how middleware works. + +### ✅ CORRECT - Using AuthenticatedMedusaRequest + +```typescript +// api/store/reviews/[id]/route.ts +// Middleware already applied: authenticate("customer", ["session", "bearer"]) +import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { deleteReviewWorkflow } from "../../../../workflows/delete-review" + +export async function DELETE( + req: AuthenticatedMedusaRequest, // ✅ Use AuthenticatedMedusaRequest for protected routes + res: MedusaResponse +) { + const { id } = req.params + // ✅ CORRECT: Just use req.auth_context.actor_id directly + // The authenticate middleware guarantees this exists + const customerId = req.auth_context.actor_id // No type error! + + // Pass to workflow - let the workflow handle business logic validation + const { result } = await deleteReviewWorkflow(req.scope).run({ + input: { + reviewId: id, + customerId, // Workflow will validate if review belongs to customer + }, + }) + + return res.json({ success: true }) +} +``` + +### ❌ WRONG - Using MedusaRequest for Protected Routes + +```typescript +// api/store/reviews/[id]/route.ts +// Middleware already applied: authenticate("customer", ["session", "bearer"]) +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" + +export async function DELETE( + req: MedusaRequest, // ❌ WRONG: Should use AuthenticatedMedusaRequest + res: MedusaResponse +) { + const { id } = req.params + const customerId = req.auth_context.actor_id // ❌ Type error: auth_context might be undefined + + return res.json({ success: true }) +} +``` + +### ❌ WRONG - Manual Authentication Check + +```typescript +// api/store/reviews/[id]/route.ts +// Middleware already applied: authenticate("customer", ["session", "bearer"]) +import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http" +import { MedusaError } from "@medusajs/framework/utils" + +export async function DELETE(req: MedusaRequest, res: MedusaResponse) { + const { id } = req.params + + // ❌ WRONG: Don't manually check if user is authenticated + // The authenticate middleware already did this! + if (!req.auth_context?.actor_id) { + throw new MedusaError( + MedusaError.Types.UNAUTHORIZED, + "You must be authenticated" + ) + } + + const customerId = req.auth_context.actor_id + + // Also wrong: don't validate business logic in routes + // (see workflows.md for why this should be in the workflow) + + return res.json({ success: true }) +} +``` + +**Why manual checks are wrong:** +- The `authenticate` middleware already validates authentication +- If authentication failed, the request never reaches your handler +- Manual checks suggest you don't trust or understand the middleware +- Adds unnecessary code and potential bugs + +### In Admin Routes + +```typescript +// api/admin/custom/route.ts +import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http" + +export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) { + // Get authenticated admin user ID + const userId = req.auth_context.actor_id + + const logger = req.scope.resolve("logger") + logger.info(`Request from admin user: ${userId}`) + + // Use userId to filter data or track actions + // ... + + return res.json({ success: true }) +} +``` + +### In Customer Routes + +```typescript +// api/store/reviews/route.ts +import { AuthenticatedMedusaRequest, MedusaResponse } from "@medusajs/framework/http" + +export async function POST(req: AuthenticatedMedusaRequest, res: MedusaResponse) { + // Get authenticated customer ID + const customerId = req.auth_context.actor_id + + const { product_id, rating, comment } = req.validatedBody + + // Create review associated with the authenticated customer + const { result } = await createReviewWorkflow(req.scope).run({ + input: { + customer_id: customerId, // From authenticated context + product_id, + rating, + comment, + }, + }) + + return res.json({ review: result }) +} +``` + +## Authentication Patterns + +### Pattern: User-Specific Data + +```typescript +// api/admin/my-reports/route.ts +export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) { + const userId = req.auth_context.actor_id + const query = req.scope.resolve("query") + + // Get reports created by this admin user + const { data: reports } = await query.graph({ + entity: "report", + fields: ["id", "title", "created_at"], + filters: { + created_by: userId, + }, + }) + + return res.json({ reports }) +} +``` + +### Pattern: Ownership Validation + +**⚠️ IMPORTANT**: Ownership validation is business logic and should be done in workflow steps, not API routes. The route should only pass the authenticated user ID to the workflow, and the workflow validates ownership. + +```typescript +// api/store/reviews/[id]/route.ts +// ✅ CORRECT - Pass user ID to workflow, let workflow validate ownership +export async function DELETE(req: AuthenticatedMedusaRequest, res: MedusaResponse) { + const customerId = req.auth_context.actor_id + const { id } = req.params + + // Pass to workflow - workflow will validate ownership + const { result } = await deleteReviewWorkflow(req.scope).run({ + input: { + reviewId: id, + customerId, // Workflow validates this review belongs to this customer + }, + }) + + return res.json({ success: true }) +} + +// ❌ WRONG - Don't validate ownership in the route +export async function DELETE(req: AuthenticatedMedusaRequest, res: MedusaResponse) { + const customerId = req.auth_context.actor_id + const { id } = req.params + const query = req.scope.resolve("query") + + // ❌ WRONG: Don't check ownership in the route + const { data: reviews } = await query.graph({ + entity: "review", + fields: ["id", "customer_id"], + filters: { id }, + }) + + if (!reviews || reviews.length === 0) { + throw new MedusaError(MedusaError.Types.NOT_FOUND, "Review not found") + } + + if (reviews[0].customer_id !== customerId) { + throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Not your review") + } + + // This bypasses workflow validation + await deleteReviewWorkflow(req.scope).run({ + input: { id }, + }) + + return res.status(204).send() +} +``` + +**See [workflows.md](workflows.md#business-logic-and-validation-placement) for the complete pattern of validating ownership in workflow steps.** + +### Pattern: Customer Profile Routes + +```typescript +// api/store/customers/me/wishlist/route.ts +// Automatically protected because it's under /store/customers/me/* + +export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) { + const customerId = req.auth_context.actor_id + const query = req.scope.resolve("query") + + // Get customer's wishlist + const { data: wishlists } = await query.graph({ + entity: "wishlist", + fields: ["id", "products.*"], + filters: { + customer_id: customerId, + }, + }) + + return res.json({ wishlist: wishlists[0] || null }) +} + +export async function POST(req: AuthenticatedMedusaRequest, res: MedusaResponse) { + const customerId = req.auth_context.actor_id + const { product_id } = req.validatedBody + + // Add product to customer's wishlist + const { result } = await addToWishlistWorkflow(req.scope).run({ + input: { + customer_id: customerId, + product_id, + }, + }) + + return res.json({ wishlist: result }) +} +``` + +### Pattern: Admin Action Tracking + +```typescript +// api/admin/products/[id]/archive/route.ts +export async function POST(req: AuthenticatedMedusaRequest, res: MedusaResponse) { + const adminUserId = req.auth_context.actor_id + const { id } = req.params + + // Archive product and track who did it + const { result } = await archiveProductWorkflow(req.scope).run({ + input: { + product_id: id, + archived_by: adminUserId, + archived_at: new Date(), + }, + }) + + const logger = req.scope.resolve("logger") + logger.info(`Product ${id} archived by admin user ${adminUserId}`) + + return res.json({ product: result }) +} +``` + +### Pattern: Optional Authentication + +Some routes may benefit from authentication but don't require it. Use the `authenticate` middleware with `allowUnauthenticated: true`: + +```typescript +// api/middlewares.ts +import { + defineMiddlewares, + authenticate, +} from "@medusajs/framework/http" + +export default defineMiddlewares({ + routes: [ + { + matcher: "/store/products/*/reviews", + middlewares: [ + authenticate("customer", ["session", "bearer"], { + allowUnauthenticated: true, // Allows access without authentication + }) + ], + }, + ], +}) +``` + +```typescript +// api/store/products/[id]/reviews/route.ts +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const customerId = req.auth_context?.actor_id // May be undefined + const { id } = req.params + const query = req.scope.resolve("query") + + // Get all reviews + const { data: reviews } = await query.graph({ + entity: "review", + fields: ["id", "rating", "comment", "customer_id"], + filters: { + product_id: id, + }, + }) + + // If authenticated, mark customer's own reviews + if (customerId) { + reviews.forEach(review => { + review.is_own = review.customer_id === customerId + }) + } + + return res.json({ reviews }) +} +``` + +## Frontend Integration + +### Store (Customer) Authentication + +When using the Medusa JS SDK in storefronts: + +```typescript +// Frontend code +import { sdk } from "./lib/sdk" + +// Login +await sdk.auth.login("customer", "emailpass", { + email: "customer@example.com", + password: "password", +}) + +// SDK automatically includes auth headers in subsequent requests +const { customer } = await sdk.store.customer.retrieve() + +// Access protected routes +const { orders } = await sdk.store.customer.listOrders() +``` + +### Admin Authentication + +When using the Medusa JS SDK in admin applications: + +```typescript +// Admin frontend code +import { sdk } from "./lib/sdk" + +// Login +await sdk.auth.login("user", "emailpass", { + email: "admin@example.com", + password: "password", +}) + +// SDK automatically includes JWT in Authorization header +const { products } = await sdk.admin.product.list() +``` + +## Security Best Practices + +### 1. Use Actor ID from Context + +```typescript +// ✅ GOOD: Uses authenticated context +const customerId = req.auth_context.actor_id + +// ❌ BAD: Takes user ID from request +const { customer_id } = req.validatedBody // ❌ Can be spoofed +``` + +### 2. Appropriate Authentication Methods + +```typescript +// ✅ GOOD: Admin routes support all methods +authenticate("user", ["session", "bearer", "api-key"]) + +// ✅ GOOD: Customer routes use session/bearer only +authenticate("customer", ["session", "bearer"]) + +// ❌ BAD: Customer routes with API key +authenticate("customer", ["api-key"]) // API keys are for admin only +``` + +### 3. Don't Expose Sensitive Data + +```typescript +// ✅ GOOD: Filters sensitive fields +export async function GET(req: AuthenticatedMedusaRequest, res: MedusaResponse) { + const customerId = req.auth_context.actor_id + + const customer = await getCustomer(customerId) + + // Remove sensitive data before sending + delete customer.password_hash + delete customer.metadata?.internal_notes + + return res.json({ customer }) +} +``` diff --git a/.agents/skills/building-with-medusa/reference/custom-modules.md b/.agents/skills/building-with-medusa/reference/custom-modules.md new file mode 100644 index 0000000..2903d5b --- /dev/null +++ b/.agents/skills/building-with-medusa/reference/custom-modules.md @@ -0,0 +1,240 @@ +# Custom Modules + +## Contents +- [When to Create a Custom Module](#when-to-create-a-custom-module) +- [Module Structure](#module-structure) +- [Creating a Custom Module - Implementation Checklist](#creating-a-custom-module---implementation-checklist) +- [Step 1: Create the Data Model](#step-1-create-the-data-model) +- [Step 2: Create the Service](#step-2-create-the-service) +- [Step 3: Export Module Definition](#step-3-export-module-definition) +- [Step 4: Register in Configuration](#step-4-register-in-configuration) +- [Steps 5-6: Generate and Run Migrations](#steps-5-6-generate-and-run-migrations) +- [Resolving Services from Container](#resolving-services-from-container) +- [Auto-Generated CRUD Methods](#auto-generated-crud-methods) +- [Loaders](#loaders) + +A module is a reusable package of functionalities related to a single domain or integration. Modules contain data models (database tables) and a service class that provides methods to manage them. + +## When to Create a Custom Module + +- **New domain concepts**: Brands, wishlists, reviews, loyalty points +- **Third-party integrations**: ERPs, CMSs, custom services +- **Isolated business logic**: Features that don't fit existing commerce modules + +## Module Structure + +``` +src/modules/blog/ +├── models/ +│ └── post.ts # Data model definitions +├── service.ts # Main service class +└── index.ts # Module definition export +``` + +## Creating a Custom Module - Implementation Checklist + +**IMPORTANT FOR CLAUDE CODE**: When implementing custom modules, use the TodoWrite tool to track your progress through these steps. This ensures you don't miss any critical steps (especially migrations!) and provides visibility to the user. + +Create these tasks in your todo list: + +- Create data model in src/modules/[name]/models/ +- Create service extending MedusaService +- Export module definition in index.ts +- **CRITICAL: Register module in medusa-config.ts** (do this before using the module) +- **CRITICAL: Generate migrations: npx medusa db:generate [module-name]** (Never skip!) +- **CRITICAL: Run migrations: npx medusa db:migrate** (Never skip!) +- Use module service in API routes/workflows +- **CRITICAL: Run build to validate implementation** (catches type errors and issues) + +## Step 1: Create the Data Model + +```typescript +// src/modules/blog/models/post.ts +import { model } from "@medusajs/framework/utils" + +const Post = model.define("post", { + id: model.id().primaryKey(), + title: model.text(), + content: model.text().nullable(), + published: model.boolean().default(false), +}) + +// note models automatically get created_at, updated_at and deleted_at added - don't add these explicitly + +export default Post +``` + +**Data model reference**: See [data-models.md](data-models.md) + +## Step 2: Create the Service + +```typescript +// src/modules/blog/service.ts +import { MedusaService } from "@medusajs/framework/utils" +import Post from "./models/post" + +class BlogModuleService extends MedusaService({ + Post, +}) {} + +export default BlogModuleService +``` + +The service extends `MedusaService` which auto-generates CRUD methods for each data model. + +## Step 3: Export Module Definition + +```typescript +// src/modules/blog/index.ts +import BlogModuleService from "./service" +import { Module } from "@medusajs/framework/utils" + +export const BLOG_MODULE = "blog" + +export default Module(BLOG_MODULE, { + service: BlogModuleService, +}) +``` + +**⚠️ CRITICAL - Module Name Format:** +- Module names MUST be in camelCase +- **NEVER use dashes (kebab-case)** in module names +- ✅ CORRECT: `"blog"`, `"productReview"`, `"orderTracking"` +- ❌ WRONG: `"product-review"`, `"order-tracking"` (will cause runtime errors) + +**Example of common mistake:** + +```typescript +// ❌ WRONG - dashes will break the module +export const PRODUCT_REVIEW_MODULE = "product-review" // Don't do this! +export default Module("product-review", { service: ProductReviewService }) + +// ✅ CORRECT - use camelCase +export const PRODUCT_REVIEW_MODULE = "productReview" +export default Module("productReview", { service: ProductReviewService }) +``` + +**Why this matters:** Medusa's internal module resolution uses property access syntax (e.g., `container.resolve("productReview")`), and dashes would break this. + +## Step 4: Register in Configuration + +**IMPORTANT**: You MUST register the module in the configurations BEFORE using it anywhere or generating migrations. + +```typescript +// medusa-config.ts +module.exports = defineConfig({ + // ... + modules: [{ resolve: "./src/modules/blog" }], +}) +``` + +## Steps 5-6: Generate and Run Migrations + +**⚠️ CRITICAL - DO NOT SKIP**: After creating a module and registering it in medusa-config.ts, you MUST run TWO SEPARATE commands. Without this step, the module's database tables won't exist and you will get runtime errors. + +```bash +# Step 5: Generate migrations (creates migration files) +# Command format: npx medusa db:generate +npx medusa db:generate blog + +# Step 6: Run migrations (applies changes to database) +# This command takes NO arguments +npx medusa db:migrate +``` + +**⚠️ CRITICAL: These are TWO separate commands:** +- ✅ CORRECT: Run `npx medusa db:generate blog` then `npx medusa db:migrate` +- ❌ WRONG: `npx medusa db:generate blog "create blog module"` (no description parameter!) +- ❌ WRONG: Combining into one command + +**Why this matters:** +- Migrations create the database tables for your module's data models +- Without migrations, the module service methods (createPosts, listPosts, etc.) will fail +- You must generate migrations BEFORE running them +- This step is REQUIRED before using the module anywhere in your code + +**Common mistake:** Creating a module and immediately trying to use it in a workflow or API route without running migrations first. Always run migrations immediately after registering the module. + +## Resolving Services from Container + +Access your module service in different contexts: + +```typescript +// In API routes +const blogService = req.scope.resolve("blog") +const post = await blogService.createPosts({ title: "Hello World" }) + +// In workflow steps +const blogService = container.resolve("blog") +const posts = await blogService.listPosts({ published: true }) +``` + +The module name used in `Module("blog", ...)` becomes the container resolution key. + +## Auto-Generated CRUD Methods + +The service auto-generates methods for each data model: + +```typescript +// Create - pass object or array of objects +const post = await blogService.createPosts({ title: "Hello" }) +const posts = await blogService.createPosts([ + { title: "One" }, + { title: "Two" }, +]) + +// Retrieve - by ID, with optional select/relations +const post = await blogService.retrievePost("post_123") +const post = await blogService.retrievePost("post_123", { + select: ["id", "title"], +}) + +// List - with filters and options +const posts = await blogService.listPosts() +const posts = await blogService.listPosts({ published: true }) +const posts = await blogService.listPosts( + { published: true }, // filters + { take: 20, skip: 0, order: { created_at: "DESC" } } // options +) + +// List with count - returns [records, totalCount] +const [posts, count] = await blogService.listAndCountPosts({ published: true }) + +// Update - by ID or with selector/data pattern +const post = await blogService.updatePosts({ id: "post_123", title: "Updated" }) +const posts = await blogService.updatePosts({ + selector: { published: false }, + data: { published: true }, +}) + +// Delete - by ID, array of IDs, or filter object +await blogService.deletePosts("post_123") +await blogService.deletePosts(["post_123", "post_456"]) +await blogService.deletePosts({ published: false }) + +// Soft delete / restore +await blogService.softDeletePosts("post_123") +await blogService.restorePosts("post_123") +``` + +## Loaders + +Loaders run when the Medusa application starts. Use them to initialize connections, seed data (relevant to the Module), or register resources. + +```typescript +// src/modules/blog/loaders/hello-world.ts +import { LoaderOptions } from "@medusajs/framework/types" + +export default async function helloWorldLoader({ container }: LoaderOptions) { + const logger = container.resolve("logger") + logger.info("[BLOG MODULE] Started!") +} + +// Export in module definition (src/modules/blog/index.ts) +import helloWorldLoader from "./loaders/hello-world" + +export default Module("blog", { + service: BlogModuleService, + loaders: [helloWorldLoader], +}) +``` diff --git a/.agents/skills/building-with-medusa/reference/data-models.md b/.agents/skills/building-with-medusa/reference/data-models.md new file mode 100644 index 0000000..2f14bcc --- /dev/null +++ b/.agents/skills/building-with-medusa/reference/data-models.md @@ -0,0 +1,103 @@ +# Data Models + +Data models represent tables in the database. Use Medusa's Data Model Language (DML) to define them. + +## Property Types + +```typescript +import { model } from "@medusajs/framework/utils" + +const MyModel = model.define("my_model", { + // Primary key (required) + id: model.id().primaryKey(), + + // Text + name: model.text(), + description: model.text().nullable(), + + // Numbers + quantity: model.number(), + price: model.bigNumber(), // For high precision + + // Boolean + is_active: model.boolean().default(true), + + // Enum + status: model.enum(["draft", "published", "archived"]).default("draft"), + + // Date/Time + published_at: model.dateTime().nullable(), + + // JSON (for flexible data) + metadata: model.json().nullable(), + + // Array + tags: model.array().nullable(), +}) +``` + +## Property Modifiers + +```typescript +model.text() // Required by default +model.text().nullable() // Allow null values +model.text().default("value") // Set default value +model.text().unique() // Unique constraint +model.text().primaryKey() // Set as primary key +``` + +## Relationships Within a Module + +Define relationships between data models in the same module: + +```typescript +// src/modules/blog/models/post.ts +import { model } from "@medusajs/framework/utils" +import { Comment } from "./comment" + +export const Post = model.define("post", { + id: model.id().primaryKey(), + title: model.text(), + comments: model.hasMany(() => Comment, { + mappedBy: "post", + }), +}) + +// src/modules/blog/models/comment.ts +import { model } from "@medusajs/framework/utils" +import { Post } from "./post" + +export const Comment = model.define("comment", { + id: model.id().primaryKey(), + content: model.text(), + post: model.belongsTo(() => Post, { + mappedBy: "comments", + }), +}) +``` + +## Relationship Types + +- `model.hasMany()` - One-to-many (post has many comments) +- `model.belongsTo()` - Many-to-one (comment belongs to post) +- `model.hasOne()` - One-to-one +- `model.manyToMany()` - Many-to-many + +## Automatic Properties + +Data models automatically include: + +- `created_at` - Creation timestamp +- `updated_at` - Last update timestamp +- `deleted_at` - Soft delete timestamp + +**Important**: Never add these properties explicitly to your model definitions. + +## Generate and Run Migrations After Changes + +After making changes to a data model, such as adding a property, you MUST generate migrations BEFORE running migrations: + +```bash +npx medusa db:generate blog +npx medusa db:migrate +``` diff --git a/.agents/skills/building-with-medusa/reference/error-handling.md b/.agents/skills/building-with-medusa/reference/error-handling.md new file mode 100644 index 0000000..c276838 --- /dev/null +++ b/.agents/skills/building-with-medusa/reference/error-handling.md @@ -0,0 +1,254 @@ +# Error Handling in Medusa + +Medusa provides the `MedusaError` class for consistent error responses across your API routes and custom code. + +## Contents +- [Using MedusaError](#using-medusaerror) +- [Error Types](#error-types) +- [Error Response Format](#error-response-format) +- [Best Practices](#best-practices) + +## Using MedusaError + +Use `MedusaError` in API routes, workflows, and custom modules to throw errors that Medusa will automatically format and return to clients: + +```typescript +import { MedusaError } from "@medusajs/framework/utils" + +// Throw an error +throw new MedusaError( + MedusaError.Types.NOT_FOUND, + "Product not found" +) +``` + +## Error Types + +### NOT_FOUND +Use when a requested resource doesn't exist: + +```typescript +throw new MedusaError( + MedusaError.Types.NOT_FOUND, + "Product with ID 'prod_123' not found" +) +``` + +**HTTP Status**: 404 + +### INVALID_DATA +Use when request data fails validation or is malformed: + +```typescript +throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Email address is invalid" +) +``` + +**HTTP Status**: 400 + +### UNAUTHORIZED +Use when authentication is required but not provided: + +```typescript +throw new MedusaError( + MedusaError.Types.UNAUTHORIZED, + "Authentication required to access this resource" +) +``` + +**HTTP Status**: 401 + +### NOT_ALLOWED +Use when the user is authenticated but doesn't have permission: + +```typescript +throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "You don't have permission to delete this product" +) +``` + +**HTTP Status**: 403 + +### CONFLICT +Use when the operation conflicts with existing data: + +```typescript +throw new MedusaError( + MedusaError.Types.CONFLICT, + "A product with this handle already exists" +) +``` + +**HTTP Status**: 409 + +### DUPLICATE_ERROR +Use when trying to create a duplicate resource: + +```typescript +throw new MedusaError( + MedusaError.Types.DUPLICATE_ERROR, + "Email address is already registered" +) +``` + +**HTTP Status**: 422 + +### INVALID_STATE +Use when the resource is in an invalid state for the operation: + +```typescript +throw new MedusaError( + MedusaError.Types.INVALID_STATE, + "Cannot cancel an order that has already been fulfilled" +) +``` + +**HTTP Status**: 400 + +## Error Response Format + +Medusa automatically formats errors into a consistent JSON response: + +```json +{ + "type": "not_found", + "message": "Product with ID 'prod_123' not found" +} +``` + +## Best Practices + +### 1. Use Specific Error Types + +Choose the most appropriate error type for the situation: + +```typescript +// ✅ GOOD: Uses specific error types +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const { id } = req.params + const query = req.scope.resolve("query") + + const { data } = await query.graph({ + entity: "product", + fields: ["id", "title"], + filters: { id }, + }) + + if (!data || data.length === 0) { + throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product with ID '${id}' not found` + ) + } + + return res.json({ product: data[0] }) +} + +// ❌ BAD: Uses generic error +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const { id } = req.params + const query = req.scope.resolve("query") + + const { data } = await query.graph({ + entity: "product", + fields: ["id", "title"], + filters: { id }, + }) + + if (!data || data.length === 0) { + throw new Error("Product not found") // Generic error + } + + return res.json({ product: data[0] }) +} +``` + +### 2. Provide Clear Error Messages + +Error messages should be descriptive and help users understand what went wrong: + +```typescript +// ✅ GOOD: Clear, specific message +throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Cannot create product: title must be at least 3 characters long" +) + +// ❌ BAD: Vague message +throw new MedusaError( + MedusaError.Types.INVALID_DATA, + "Invalid input" +) +``` + +### 3. Include Context in Error Messages + +```typescript +// ✅ GOOD: Includes relevant context +throw new MedusaError( + MedusaError.Types.NOT_FOUND, + `Product with ID '${productId}' not found` +) + +// ✅ GOOD: Includes field name +throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Invalid email format: '${email}'` +) +``` + +### 4. Handle Workflow Errors + +When calling workflows from API routes, catch and transform errors: + +```typescript +// ✅ GOOD: Catches and transforms workflow errors +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const { data } = req.validatedBody + + try { + const { result } = await myWorkflow(req.scope).run({ + input: { data }, + }) + + return res.json({ result }) + } catch (error) { + // Transform workflow errors into API errors + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Failed to create resource: ${error.message}` + ) + } +} +``` + +### 5. Use Validation Middleware + +Let validation middleware handle input validation errors: + +```typescript +// ✅ GOOD: Middleware handles validation +// middlewares.ts +const MySchema = z.object({ + email: z.string().email("Invalid email address"), + age: z.number().min(18, "Must be at least 18 years old"), +}) + +export const myMiddlewares: MiddlewareRoute[] = [ + { + matcher: "/store/my-route", + method: "POST", + middlewares: [validateAndTransformBody(MySchema)], + }, +] + +// route.ts - No need to validate again +export async function POST(req: MedusaRequest, res: MedusaResponse) { + const { email, age } = req.validatedBody // Already validated + + // Your logic here +} +``` \ No newline at end of file diff --git a/.agents/skills/building-with-medusa/reference/frontend-integration.md b/.agents/skills/building-with-medusa/reference/frontend-integration.md new file mode 100644 index 0000000..d93292c --- /dev/null +++ b/.agents/skills/building-with-medusa/reference/frontend-integration.md @@ -0,0 +1,229 @@ +# Frontend SDK Integration + +## Contents +- [Frontend SDK Pattern](#frontend-sdk-pattern) + - [Locating the SDK](#locating-the-sdk) + - [Using sdk.client.fetch()](#using-sdkclientfetch) +- [React Query Pattern](#react-query-pattern) +- [Query Key Best Practices](#query-key-best-practices) +- [Error Handling](#error-handling) +- [Optimistic Updates](#optimistic-updates) + +This guide covers how to integrate Medusa custom API routes with frontend applications using the Medusa SDK and React Query. + +**Note:** API routes are also referred to as "endpoints" - these terms are interchangeable. + +## Frontend SDK Pattern + +### Locating the SDK + +**IMPORTANT:** Never hardcode SDK import paths. Always locate where the SDK is instantiated in the project first. + +Look for `@medusajs/js-sdk` + +The SDK instance is typically exported as `sdk`: + +```typescript +import { sdk } from "[LOCATE IN PROJECT]" +``` + +### Using sdk.client.fetch() + +**⚠️ CRITICAL: ALWAYS use the Medusa JS SDK for ALL API requests - NEVER use regular fetch()** + +**Why this is critical:** +- **Store API routes** require the publishable API key in headers +- **Admin API routes** require authentication headers +- **Regular fetch()** without these headers will cause errors +- The SDK automatically handles all required headers for you + +**When to use what:** +- **Existing endpoints** (built-in Medusa routes): Use existing SDK methods like `sdk.store.product.list()`, `sdk.admin.order.retrieve()` +- **Custom endpoints** (your custom API routes): Use `sdk.client.fetch()` for custom routes + +**⚠️ CRITICAL: The SDK handles JSON serialization automatically. NEVER use JSON.stringify() on the body.** + +Call custom API routes using the SDK: + +```typescript +import { sdk } from "[LOCATE SDK INSTANCE IN PROJECT]" + +// ✅ CORRECT - Pass object directly +const result = await sdk.client.fetch("/store/my-route", { + method: "POST", + body: { + email: "user@example.com", + name: "John Doe", + }, +}) + +// ❌ WRONG - Don't use JSON.stringify +const result = await sdk.client.fetch("/store/my-route", { + method: "POST", + body: JSON.stringify({ // ❌ DON'T DO THIS! + email: "user@example.com", + }), +}) +``` + +**Key points:** + +- **The SDK handles JSON serialization automatically** - just pass plain objects +- **NEVER use JSON.stringify()** - this will break the request +- No need to set Content-Type headers - SDK adds them +- Session/JWT authentication is handled automatically +- Publishable API key is automatically added + +### Built-in Endpoints vs Custom Endpoints + +**⚠️ CRITICAL: Use the appropriate SDK method based on endpoint type** + +```typescript +import { sdk } from "[LOCATE SDK INSTANCE IN PROJECT]" + +// ✅ CORRECT - Built-in endpoint: Use existing SDK method +const products = await sdk.store.product.list({ + limit: 10, + offset: 0 +}) + +// ✅ CORRECT - Custom endpoint: Use sdk.client.fetch() +const reviews = await sdk.client.fetch("/store/products/prod_123/reviews") + +// ❌ WRONG - Using regular fetch for ANY endpoint +const products = await fetch("http://localhost:9000/store/products") +// ❌ Error: Missing publishable API key header! + +// ❌ WRONG - Using regular fetch for custom endpoint +const reviews = await fetch("http://localhost:9000/store/products/prod_123/reviews") +// ❌ Error: Missing publishable API key header! + +// ❌ WRONG - Using sdk.client.fetch() for built-in endpoint when SDK method exists +const products = await sdk.client.fetch("/store/products") +// ❌ Less type-safe than using sdk.store.product.list() +``` + +**Why this matters:** +- **Store routes** require `x-publishable-api-key` header - SDK adds it automatically +- **Admin routes** require `Authorization` and session cookie headers - SDK adds them automatically +- **Regular fetch()** doesn't include these headers → API returns authentication/authorization errors +- Using existing SDK methods provides **better type safety** and autocomplete + +## React Query Pattern + +Use `useQuery` for GET requests and `useMutation` for POST/DELETE: + +```typescript +import { sdk } from "[LOCATE SDK INSTANCE IN PROJECT]" +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" + +function MyComponent({ userId }: { userId: string }) { + const queryClient = useQueryClient() + + // GET request - fetching data + const { data, isLoading } = useQuery({ + queryKey: ["my-data", userId], + queryFn: () => sdk.client.fetch(`/store/my-route?userId=${userId}`), + enabled: !!userId, + }) + + // POST request - mutation with cache invalidation + const mutation = useMutation({ + mutationFn: (input: { email: string }) => + sdk.client.fetch("/store/my-route", { method: "POST", body: input }), + onSuccess: () => { + // Invalidate and refetch related queries + queryClient.invalidateQueries({ queryKey: ["my-data"] }) + }, + }) + + if (isLoading) return

Loading...

+ + return ( +
+

{data?.title}

+ + {mutation.isError &&

Error occurred

} +
+ ) +} +``` + +**Key states:** `isLoading`, `isPending`, `isSuccess`, `isError`, `error` + +## Query Key Best Practices + +Structure query keys for effective cache management: + +```typescript +// Good: Hierarchical structure +queryKey: ["products", productId] +queryKey: ["products", "list", { page, filters }] + +// Invalidate all product queries +queryClient.invalidateQueries({ queryKey: ["products"] }) + +// Invalidate specific product +queryClient.invalidateQueries({ queryKey: ["products", productId] }) +``` + +## Error Handling + +Handle API errors gracefully: + +```typescript +const mutation = useMutation({ + mutationFn: (input) => sdk.client.fetch("/store/my-route", { + method: "POST", + body: input + }), + onError: (error) => { + console.error("Mutation failed:", error) + // Show error message to user + }, +}) + +// In component +{mutation.isError && ( +

+ {mutation.error?.message || "An error occurred"} +

+)} +``` + +## Optimistic Updates + +Update UI immediately before server confirms: + +```typescript +const mutation = useMutation({ + mutationFn: (newItem) => + sdk.client.fetch("/store/items", { method: "POST", body: newItem }), + onMutate: async (newItem) => { + // Cancel outgoing refetches + await queryClient.cancelQueries({ queryKey: ["items"] }) + + // Snapshot previous value + const previousItems = queryClient.getQueryData(["items"]) + + // Optimistically update + queryClient.setQueryData(["items"], (old) => [...old, newItem]) + + // Return context with snapshot + return { previousItems } + }, + onError: (err, newItem, context) => { + // Rollback on error + queryClient.setQueryData(["items"], context.previousItems) + }, + onSettled: () => { + // Refetch after mutation + queryClient.invalidateQueries({ queryKey: ["items"] }) + }, +}) +``` diff --git a/.agents/skills/building-with-medusa/reference/module-links.md b/.agents/skills/building-with-medusa/reference/module-links.md new file mode 100644 index 0000000..7e7b23d --- /dev/null +++ b/.agents/skills/building-with-medusa/reference/module-links.md @@ -0,0 +1,384 @@ +# Module Links + +## Contents +- [When to Use Links](#when-to-use-links) +- [Implementing Module Links - Workflow Checklist](#implementing-module-links---workflow-checklist) +- [Step 1: Defining a Link](#step-1-defining-a-link) +- [Step 2: Link Configuration Options](#step-2-link-configuration-options) + - [List Links (One-to-Many)](#list-links-one-to-many) + - [Delete Cascades](#delete-cascades) +- [Step 3: Sync Links (Run Migrations)](#step-3-sync-links-run-migrations) +- [Step 4: Managing Links](#step-4-managing-links) +- [Step 5: Querying Linked Data](#step-5-querying-linked-data) +- [Advanced: Link with Custom Columns](#advanced-link-with-custom-columns) + +Module links create associations between data models in different modules while maintaining module isolation. Use links to connect your custom models to Commerce Module models (products, customers, orders, etc.). + +## When to Use Links + +- **Extend commerce entities**: Add brands to products, wishlists to customers +- **Cross-module associations**: Connect custom modules to each other +- **Maintain isolation**: Keep modules independent and reusable + +## Implementing Module Links - Workflow Checklist + +**IMPORTANT FOR CLAUDE CODE**: When implementing module links, use the TodoWrite tool to track your progress through these steps. This ensures you don't miss any critical steps and provides visibility to the user. + +Create these tasks in your todo list: + +- Optional: Add linked ID in custom data model (if one-to-one or one-to-many) +- Define the link in src/links/ +- Configure list or delete cascade options if needed +- **CRITICAL: Run migrations: npx medusa db:migrate** (Never skip this step!) +- Create links in code using link.create() or createRemoteLinkStep +- Query linked data using query.graph() +- **CRITICAL: Run build to validate implementation** (catches type errors and issues) + +## Optional: Add Linked ID in Custom Data Model + +Add the ID of a linked data model in the custom data model if the custom data model belongs to it or extends it. Otherwise, skip this step. + +For example, add ID of customer and product to custom product review model: + +```typescript +import { model } from "@medusajs/framework/utils" + +const Review = model.define("review", { + // other properties... + // ID of linked customer + customer_id: model.text(), + // ID of linked product + product_id: model.text() +}) + +export default Review +``` + +## Step 1: Defining a Link + +**⚠️ CRITICAL RULE: Create ONE link definition per file.** Do NOT export an array of links from a single file. + +Create link files in `src/links/`: + +```typescript +// ✅ CORRECT - src/links/product-brand.ts (one link per file) +import { defineLink } from "@medusajs/framework/utils" +import ProductModule from "@medusajs/medusa/product" +import BrandModule from "../modules/brand" + +export default defineLink( + ProductModule.linkable.product, + BrandModule.linkable.brand +) +``` + +**If one model links to multiple others, create multiple files:** + +```typescript +// ✅ CORRECT - src/links/review-product.ts +export default defineLink( + ReviewModule.linkable.review, + ProductModule.linkable.product +) + +// ✅ CORRECT - src/links/review-customer.ts +export default defineLink( + ReviewModule.linkable.review, + CustomerModule.linkable.customer +) + +// ❌ WRONG - Don't export array of links from one file +export default [ + defineLink(ReviewModule.linkable.review, ProductModule.linkable.product), + defineLink(ReviewModule.linkable.review, CustomerModule.linkable.customer), +] // This doesn't work! +``` + +**IMPORTANT:** The `.linkable` property is **automatically added** to all modules by Medusa. You do NOT need to add `.linkable()` or any linkable definition to your data models. Simply use `ModuleName.linkable.modelName` when defining links. + +For example, if you have a `Review` data model in a `ReviewModule`: +- ✅ CORRECT: `ReviewModule.linkable.review` (works automatically) +- ❌ WRONG: Adding `.linkable()` method to the Review model definition (not needed, causes errors) + +**⚠️ NEXT STEP**: After defining a link, you MUST immediately proceed to Step 3 to run migrations (`npx medusa db:migrate`). Do not skip this step! + +## Step 2: Link Configuration Options + +### List Links (One-to-Many) + +Allow multiple records to link to one record: + +```typescript +// A brand can have many products +export default defineLink( + { + linkable: ProductModule.linkable.product, + isList: true, + }, + BrandModule.linkable.brand +) +``` + +### Delete Cascades + +Automatically delete links when a record is deleted: + +```typescript +export default defineLink(ProductModule.linkable.product, { + linkable: BrandModule.linkable.brand, + deleteCascade: true, +}) +``` + +## Step 3: Sync Links (Run Migrations) + +**⚠️ CRITICAL - DO NOT SKIP**: After defining links, you MUST run migrations to sync the link to the database. Without this step, the link will not work and you will get runtime errors. + +```bash +npx medusa db:migrate +``` + +**Why this matters:** +- Links create database tables that store the relationships between modules +- Without migrations, these tables don't exist and link operations will fail +- This step is REQUIRED before creating any links in code or querying linked data + +**Common mistake:** Defining a link in `src/links/` and immediately trying to use it in a workflow or query without running migrations first. Always run migrations immediately after defining a link. + +## Step 4: Managing Links + +**⚠️ CRITICAL - Link Order (Direction):** When creating or dismissing links, the order of modules MUST match the order in `defineLink()`. Mismatched order causes runtime errors. + +```typescript +// Example link definition: product FIRST, then brand +export default defineLink( + ProductModule.linkable.product, + BrandModule.linkable.brand +) +``` + +### In Workflow Composition Functions + +To create a link between records in workflow composition functions, use the `createRemoteLinkStep`: + +```typescript +import { Modules } from "@medusajs/framework/utils" +import { createRemoteLinkStep } from "@medusajs/medusa/core-flows" +import { + createWorkflow, + transform, +} from "@medusajs/framework/workflows-sdk" + +const BRAND_MODULE = "brand" + +export const myWorkflow = createWorkflow( + "my-workflow", + function (input) { + // ... + // ✅ CORRECT - Order matches defineLink (product first, then brand) + const linkData = transform({ input }, ({ input }) => { + return [ + { + [Modules.PRODUCT]: { + product_id: input.product_id, + }, + [BRAND_MODULE]: { + brand_id: input.brand_id, + }, + }, + ] + }) + + createRemoteLinkStep(linkData) + // ... + } +) + +// ❌ WRONG - Order doesn't match defineLink +const linkData = transform({ input }, ({ input }) => { + return [ + { + [BRAND_MODULE]: { + brand_id: input.brand_id, + }, + [Modules.PRODUCT]: { + product_id: input.product_id, + }, + }, + ] +}) // Runtime error: link direction mismatch! +``` + +To dismiss (remove) a link between records in workflow composition functions, use the `dismissRemoteLinkStep`: + +```typescript +import { Modules } from "@medusajs/framework/utils" +import { dismissRemoteLinkStep } from "@medusajs/medusa/core-flows" +import { + createWorkflow, + transform, +} from "@medusajs/framework/workflows-sdk" + +const BRAND_MODULE = "brand" + +export const myWorkflow = createWorkflow( + "my-workflow", + function (input) { + // ... + // Order MUST match defineLink (product first, then brand) + const linkData = transform({ input }, ({ input }) => { + return [ + { + [Modules.PRODUCT]: { + product_id: input.product_id, + }, + [BRAND_MODULE]: { + brand_id: input.brand_id, + }, + }, + ] + }) + + dismissRemoteLinkStep(linkData) + // ... + } +) +``` + +### Outside Workflows + +Outside workflows or in workflow steps, use the `link` utility to create and manage links between records. **Order MUST match `defineLink()` here too:** + +```typescript +import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils" + +// In an API route or workflow step +const link = container.resolve(ContainerRegistrationKeys.LINK) + +const BRAND_MODULE = "brand" + +// ✅ CORRECT - Create a link (order matches defineLink: product first, then brand) +await link.create({ + [Modules.PRODUCT]: { product_id: "prod_123" }, + [BRAND_MODULE]: { brand_id: "brand_456" }, +}) + +// ✅ CORRECT - Dismiss (remove) a link (same order: product first, then brand) +await link.dismiss({ + [Modules.PRODUCT]: { product_id: "prod_123" }, + [BRAND_MODULE]: { brand_id: "brand_456" }, +}) + +// ❌ WRONG - Order doesn't match defineLink +await link.create({ + [BRAND_MODULE]: { brand_id: "brand_456" }, + [Modules.PRODUCT]: { product_id: "prod_123" }, +}) // Runtime error: link direction mismatch! +``` + +## Step 5: Querying Linked Data + +### Using query.graph() - Retrieve Linked Data + +Use `query.graph()` to fetch data across linked modules. **Note**: `query.graph()` can retrieve linked data but **cannot filter by properties of linked modules** (data models in separate modules). + +```typescript +const query = container.resolve("query") + +// ✅ Get products with their linked brands (no cross-module filtering) +const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "title", "brand.*"], // brand.* fetches linked brand data + filters: { + id: "prod_123", // ✅ Filter by product properties only + }, +}) + +// ✅ Get brands with their linked products +const { data: brands } = await query.graph({ + entity: "brand", + fields: ["id", "name", "products.*"], +}) + +// ❌ DOES NOT WORK: Cannot filter products by linked brand properties +const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "title", "brand.*"], + filters: { + brand: { + name: "Nike" // ❌ Fails: brand is in a different module + } + } +}) +``` + +### Using query.index() - Filter Across Linked Modules + +To filter by properties of linked modules (separate modules with module links), use `query.index()` from the Index Module: + +```typescript +const query = container.resolve("query") + +// ✅ Filter products by linked brand name using Index Module +const { data: products } = await query.index({ + entity: "product", + fields: ["*", "brand.*"], + filters: { + brand: { + name: "Nike" // ✅ Works with Index Module! + } + } +}) +``` + +**Key Distinction:** +- **Same module relations** (e.g., Product → ProductVariant): Use `query.graph()` - filtering works ✅ +- **Different module links** (e.g., Product → Brand): Use `query.index()` for filtering ✅ + +**Index Module Requirements:** +1. Install `@medusajs/index` package +2. Add to `medusa-config.ts` +3. Enable `MEDUSA_FF_INDEX_ENGINE=true` in `.env` +4. Run `npx medusa db:migrate` +5. Mark properties as `filterable` in link definition: + +```typescript +// src/links/product-brand.ts +defineLink( + { linkable: ProductModule.linkable.product, isList: true }, + { linkable: BrandModule.linkable.brand, filterable: ["id", "name"] } +) +``` + +See the [Querying Data reference](querying-data.md#querying-linked-data) for complete details on both methods. + +## Advanced: Link with Custom Columns + +Add extra data to the link table: + +```typescript +export default defineLink( + ProductModule.linkable.product, + BrandModule.linkable.brand, + { + database: { + extraColumns: { + featured: { + type: "boolean", + defaultValue: "false", + }, + }, + }, + } +) +``` + +Set custom column values when creating links: + +```typescript +await link.create({ + product: { product_id: "prod_123" }, + brand: { brand_id: "brand_456" }, + data: { featured: true }, +}) +``` diff --git a/.agents/skills/building-with-medusa/reference/querying-data.md b/.agents/skills/building-with-medusa/reference/querying-data.md new file mode 100644 index 0000000..4feba85 --- /dev/null +++ b/.agents/skills/building-with-medusa/reference/querying-data.md @@ -0,0 +1,1014 @@ +# Querying Data in Medusa + +Medusa's Query API (`query.graph()`) is the primary way to retrieve data, especially across modules. It provides a flexible, performant way to query entities with relations and filters. + +## Contents +- [When to Use Query vs Module Services](#when-to-use-query-vs-module-services) +- [Basic Query Structure](#basic-query-structure) +- [In Workflows vs Outside Workflows](#in-workflows-vs-outside-workflows) +- [Field Selection](#field-selection) +- [Filtering](#filtering) +- [Important Filtering Limitation](#important-filtering-limitation) +- [Pagination](#pagination) +- [Querying Linked Data](#querying-linked-data) + - [Option 1: query.graph() - Retrieve Linked Data Without Cross-Module Filters](#option-1-querygraph---retrieve-linked-data-without-cross-module-filters) + - [Option 2: query.index() - Filter Across Linked Modules (Index Module)](#option-2-queryindex---filter-across-linked-modules-index-module) +- [Validation with throwIfKeyNotFound](#validation-with-throwifkeynotfound) +- [Performance Best Practices](#performance-best-practices) + +## When to Use Query vs Module Services + +**⚠️ USE QUERY FOR**: +- ✅ Retrieving data **across modules** (products with linked brands, orders with customers) +- ✅ Reading data with linked entities +- ✅ Complex queries with multiple relations +- ✅ Storefront and admin data retrieval + +**⚠️ USE MODULE SERVICES FOR**: +- ✅ Retrieving data **within a single module** (products with variants - same module) +- ✅ Using `listAndCount` for pagination within one module +- ✅ Mutations (always use module services or workflows) + +**Examples:** +```typescript +// ✅ GOOD: Query for cross-module data +const { data } = await query.graph({ + entity: "product", + fields: ["id", "title", "brand.*"], // brand is in different module +}) + +// ✅ GOOD: Module service for single module +const [products, count] = await productService.listAndCountProducts( + { status: "active" }, + { take: 10, skip: 0 } +) +``` + +## Basic Query Structure + +```typescript +const query = req.scope.resolve("query") + +const { data } = await query.graph({ + entity: "entity_name", // The entity to query + fields: ["id", "name"], // Fields to retrieve + filters: { status: "active" }, // Filter conditions + pagination: { // Optional pagination + take: 10, + skip: 0, + }, +}) +``` + +## In Workflows vs Outside Workflows + +### Outside Workflows (API Routes, Subscribers, Scheduled Jobs) + +```typescript +// In API routes +const query = req.scope.resolve("query") + +const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "title"], +}) + +// In subscribers/scheduled jobs +const query = container.resolve("query") + +const { data: customers } = await query.graph({ + entity: "customer", + fields: ["id", "email"], +}) +``` + +### In Workflows + +Use `useQueryGraphStep` within workflow composition functions: + +```typescript +import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { useQueryGraphStep } from "@medusajs/medusa/core-flows" + +const myWorkflow = createWorkflow( + "my-workflow", + function (input) { + const { data: products } = useQueryGraphStep({ + entity: "product", + fields: ["id", "title"], + filters: { + id: input.product_id, + }, + }) + + return new WorkflowResponse({ products }) + } +) +``` + +## Field Selection + +### Basic Fields + +```typescript +const { data } = await query.graph({ + entity: "product", + fields: ["id", "title", "description"], +}) +``` + +### Nested Relations + +Use dot notation to include related entities: + +```typescript +const { data } = await query.graph({ + entity: "product", + fields: [ + "id", + "title", + "variants.*", // All fields from variants + "variants.sku", // Specific variant field + "category.id", + "category.name", + ], +}) +``` + +### Performance Tip + +**⚠️ IMPORTANT**: Only retrieve fields and relations you'll actually use. Avoid using `*` to select all fields or retrieving all fields of a relation unnecessarily. + +```typescript +// ❌ BAD: Retrieves all fields (inefficient) +fields: ["*"] + +// ❌ BAD: Retrieves all product fields (might be many) +fields: ["product.*"] + +// ✅ GOOD: Only retrieves needed fields +fields: ["id", "title", "product.id", "product.title"] +``` + +## Filtering + +### Exact Match + +```typescript +filters: { + email: "user@example.com" +} +``` + +### Multiple Values (IN operator) + +```typescript +filters: { + id: ["id1", "id2", "id3"] +} +``` + +### Range Queries + +```typescript +filters: { + created_at: { + $gte: startDate, // Greater than or equal + $lte: endDate, // Less than or equal + } +} +``` + +### Text Search (LIKE) + +```typescript +filters: { + name: { + $like: "%search%" // Contains "search" + } +} + +// Starts with +filters: { + name: { + $like: "search%" + } +} + +// Ends with +filters: { + name: { + $like: "%search" + } +} +``` + +### Not Equal + +```typescript +filters: { + status: { + $ne: "deleted" + } +} +``` + +### Multiple Conditions + +```typescript +filters: { + status: "active", + created_at: { + $gte: new Date("2024-01-01"), + }, + price: { + $gte: 10, + $lte: 100, + }, +} +``` + +### Filtering Nested Relations (Same Module) + +To filter by fields in nested relations **within the same module**, use object notation: + +```typescript +// Product and ProductVariant are in the same module (Product Module) +const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "title", "variants.*"], + filters: { + variants: { + sku: "ABC1234" // ✅ Works: variants are in same module as product + } + } +}) +``` + +## Important Filtering Limitation + +**⚠️ CRITICAL**: With `query.graph()`, you **CANNOT** filter by fields from linked data models in different modules. The `query.graph()` method only supports filters on data models within the same module. + +### What This Means + +- **Same Module** (✅ Can filter with `query.graph()`): Product and ProductVariant, Order and LineItem, Cart and CartItem +- **Different Modules** (❌ Cannot filter with `query.graph()`): Product and Brand (custom), Product and Customer, Review and Product +- **Different Modules** (✅ Can filter with `query.index()`): Any linked modules when using the Index Module + +### Example: Cannot Filter Products by Linked Brand with query.graph() + +```typescript +// ❌ THIS DOES NOT WORK with query.graph() +const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "title", "brand.*"], + filters: { + "brand.name": "Nike" // ❌ Cannot filter by linked module field + } +}) + +// ❌ THIS ALSO DOES NOT WORK with query.graph() +const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "title", "brand.*"], + filters: { + brand: { + name: "Nike" // ❌ Still doesn't work - brand is in different module + } + } +}) +``` + +### Solution 1: Use query.index() with Index Module (Recommended) + +**✅ BEST APPROACH**: Use the Index Module to filter across linked modules efficiently at the database level: + +```typescript +// ✅ CORRECT: Use query.index() to filter products by linked brand +const { data: products } = await query.index({ + entity: "product", + fields: ["*", "brand.*"], + filters: { + brand: { + name: "Nike" // ✅ Works with Index Module! + } + } +}) +``` + +**Why this is best:** +- Database-level filtering (most efficient) +- Supports pagination properly +- Only retrieves the data you need +- Designed specifically for cross-module filtering + +**Requirements:** +- Index Module must be installed and configured +- Link must have `filterable` properties defined +- See [Querying Linked Data](#querying-linked-data) section for setup details + +### Solution 2: Query from Other Side + +**✅ GOOD ALTERNATIVE**: Query the linked module and filter on it directly using `query.graph()`: + +```typescript +// ✅ CORRECT: Query brands and get their products +const { data: brands } = await query.graph({ + entity: "brand", + fields: ["id", "name", "products.*"], + filters: { + name: "Nike" // ✅ Filter on brand directly + } +}) + +// Access Nike products +const nikeProducts = brands[0]?.products || [] +``` + +**Use this when:** +- You don't have the Index Module set up +- The "other side" of the link makes sense as the primary entity +- You need a quick solution without additional setup + +### Solution 3: Filter After Query (Least Efficient) + +**⚠️ LAST RESORT**: Query all data with `query.graph()`, then filter in JavaScript: + +```typescript +// Get all products with brands +const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "title", "brand.*"], +}) + +// Filter in JavaScript after query +const nikeProducts = products.filter(p => p.brand?.name === "Nike") +``` + +**Only use this when:** +- Dataset is very small (< 100 records) +- Index Module is not available +- Querying from the other side doesn't make sense +- You need a temporary solution + +**Avoid because:** +- Fetches unnecessary data from database +- Inefficient for large datasets +- No pagination support at database level +- Uses more memory and network bandwidth + +### More Examples + +#### Example: Approved Reviews for a Specific Product + +When you need to filter linked data by its own properties, you have multiple options: + +```typescript +// ❌ WRONG: Cannot filter linked reviews from product query with query.graph() +const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "reviews.*"], + filters: { + id: productId, + reviews: { + status: "approved" // ❌ Doesn't work - reviews is linked module + } + } +}) + +// ❌ ALSO WRONG: Filtering in JavaScript is inefficient +const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "reviews.*"], + filters: { id: productId } +}) +const approvedReviews = products[0].reviews.filter(r => r.status === "approved") // ❌ Client-side filter + +// ✅ OPTION 1 (BEST): Use Index Module to filter cross-module +const { data: products } = await query.index({ + entity: "product", + fields: ["*", "reviews.*"], + filters: { + id: productId, + reviews: { + status: "approved" // ✅ Works with Index Module! + } + } +}) + +// ✅ OPTION 2 (GOOD): Query reviews directly with filters +const { data: reviews } = await query.graph({ + entity: "review", + fields: ["id", "rating", "comment", "product.*"], + filters: { + product_id: productId, // Filter by product + status: "approved" // Filter by review status - both in same query! + } +}) +``` + +**Why Option 1 (Index Module) is best:** +- Database-level filtering across modules +- Returns data in the structure you expect (product with reviews) +- Supports pagination properly +- Only retrieves the data you need + +**Why Option 2 (query from other side) is good:** +- No Index Module setup required +- Still uses database filtering +- Works well when the "other side" is the logical primary entity + +#### Example: Reviews for Active Products (Cross-Module) + +```typescript +// ❌ WRONG: Cannot filter by linked module with query.graph() +const { data } = await query.graph({ + entity: "review", + fields: ["id", "rating", "product.*"], + filters: { + product: { + status: "active" // Doesn't work - product is linked module + } + } +}) + +// ✅ OPTION 1 (BEST): Use Index Module +const { data: reviews } = await query.index({ + entity: "review", + fields: ["*", "product.*"], + filters: { + product: { + status: "active" // ✅ Works with Index Module! + } + } +}) + +// ✅ OPTION 2 (GOOD): Query from the other side +const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "title", "reviews.*"], + filters: { status: "active" } +}) + +// Flatten reviews if needed +const reviews = products.flatMap(p => p.reviews) +``` + +#### Example: Products with Variants (Same Module - Works!) + +```typescript +// ✅ CORRECT: Product and variants are in same module (Product Module) +// Use query.graph() - no need for Index Module +const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "title", "variants.*"], + filters: { + variants: { + inventory_quantity: { + $gte: 10 // ✅ Works: both in Product Module + } + } + } +}) +``` + +## Pagination + +### Basic Pagination + +```typescript +const { data, metadata } = await query.graph({ + entity: "product", + fields: ["id", "title"], + pagination: { + skip: 0, // Offset + take: 10, // Limit + }, +}) + +// metadata.count contains total count +console.log(`Total: ${metadata.count}`) +``` + +### With Ordering + +```typescript +const { data } = await query.graph({ + entity: "product", + fields: ["id", "title", "created_at"], + pagination: { + skip: 0, + take: 10, + order: { + created_at: "DESC", // Newest first + }, + }, +}) +``` + +### Multiple Order Fields + +```typescript +pagination: { + order: { + status: "ASC", + created_at: "DESC", + } +} +``` + +## Querying Linked Data + +When entities are linked via [module links](module-links.md), you have two options depending on your filtering needs: + +### Option 1: query.graph() - Retrieve Linked Data Without Cross-Module Filters + +**Use `query.graph()` when:** +- ✅ Retrieving linked data without filtering by linked module properties +- ✅ Filtering only by properties in the primary entity's module +- ✅ You want to include related data in the response + +**Limitations:** +- ❌ **CANNOT filter by properties of linked modules** (data models in separate modules) +- ✅ **CAN filter by properties of relations in the same module** (e.g., product.variants) + +```typescript +// ✅ WORKS: Get products with their linked brands (no cross-module filtering) +const { data: products } = await query.graph({ + entity: "product", + fields: [ + "id", + "title", + "brand.*", // All brand fields + ], + filters: { + id: "prod_123", // ✅ Filter by product property (same module) + }, +}) + +// Access linked data +console.log(products[0].brand.name) + +// ✅ WORKS: Filter by same-module relation (product and variants are in Product Module) +const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "title", "variants.*"], + filters: { + variants: { + sku: "ABC1234" // ✅ Works: variants are in same module as product + } + } +}) + +// ❌ DOES NOT WORK: Cannot filter products by linked brand name +const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "title", "brand.*"], + filters: { + brand: { + name: "Nike" // ❌ Fails: brand is in a different module + } + } +}) +``` + +**Reverse Query (From Link to Original):** + +```typescript +// Get brands with their linked products +const { data: brands } = await query.graph({ + entity: "brand", + fields: [ + "id", + "name", + "products.*", // All linked products + ], +}) + +// Access linked products +brands[0].products.forEach(product => { + console.log(product.title) +}) +``` + +### Option 2: query.index() - Filter Across Linked Modules (Index Module) + +**Use `query.index()` when:** +- ✅ You need to filter data by properties of linked modules (separate modules with module links) +- ✅ Filtering by custom data model properties linked to Commerce Module entities +- ✅ Complex cross-module queries requiring efficient database-level filtering + +**Key Distinction:** +- **Same module relations** (e.g., Product → ProductVariant): Use `query.graph()` ✅ +- **Different module links** (e.g., Product → Brand, Product → Review): Use `query.index()` ✅ + +#### When to Use query.index() + +The Index Module solves the fundamental limitation of `query.graph()`: **you cannot filter one module's data by another module's linked properties** using `query.graph()`. + +Examples of when you need `query.index()`: +- Filter products by brand name (Product Module → Brand Module) +- Filter products by review ratings (Product Module → Review Module) +- Filter customers by custom loyalty tier (Customer Module → Loyalty Module) +- Any scenario where you need to filter by properties of a linked data model in a different module + +#### Setup Requirements + +Before using `query.index()`, ensure the Index Module is configured: + +1. **Install the Index Module:** + ```bash + npm install @medusajs/index + ``` + +2. **Add to `medusa-config.ts`:** + ```typescript + module.exports = defineConfig({ + modules: [ + { + resolve: "@medusajs/index", + }, + ], + }) + ``` + +3. **Enable the feature flag in `.env`:** + ```bash + MEDUSA_FF_INDEX_ENGINE=true + ``` + +4. **Run migrations:** + ```bash + npx medusa db:migrate + ``` + +5. **Mark linked properties as filterable** in your link definition: + ```typescript + // src/links/product-brand.ts + defineLink( + { linkable: ProductModule.linkable.product, isList: true }, + { linkable: BrandModule.linkable.brand, filterable: ["id", "name"] } + ) + ``` + + The `filterable` property marks which fields can be queried across modules. + +6. **Start the application** to trigger data ingestion into the Index Module. + +#### Using query.index() + +```typescript +const query = req.scope.resolve("query") + +// ✅ CORRECT: Filter products by linked brand name using Index Module +const { data: products } = await query.index({ + entity: "product", + fields: ["*", "brand.*"], + filters: { + brand: { + name: "Nike", // ✅ Works with Index Module! + }, + }, +}) + +// ✅ CORRECT: Filter products by review ratings +const { data: products } = await query.index({ + entity: "product", + fields: ["id", "title", "reviews.*"], + filters: { + reviews: { + rating: { + $gte: 4, // Products with reviews rated 4 or higher + }, + }, + }, +}) +``` + +#### query.index() Features + +**Pagination:** +```typescript +const { data: products } = await query.index({ + entity: "product", + fields: ["*", "brand.*"], + filters: { + brand: { name: "Nike" }, + }, + pagination: { + take: 20, + skip: 0, + }, +}) +``` + +**Advanced Filters:** +```typescript +const { data: products } = await query.index({ + entity: "product", + fields: ["*", "brand.*"], + filters: { + brand: { + name: { + $like: "%Acme%", // LIKE operator + }, + }, + status: { + $ne: "deleted", // Not equal + }, + }, +}) +``` + +#### query.graph() vs query.index() Decision Tree + +``` +Need to filter by linked module properties? +├─ No → Use query.graph() +│ └─ Faster, simpler, works for most queries +│ +└─ Yes → Are the entities in the same module or different modules? + ├─ Same module (e.g., product.variants) → Use query.graph() + │ └─ Example: Product and ProductVariant both in Product Module + │ + └─ Different modules (e.g., product → brand) → Use query.index() + └─ Example: Product (Product Module) → Brand (Custom Module) + └─ Requires Index Module setup and filterable properties +``` + +#### Important Notes + +- **Performance:** The Index Module pre-ingests data on application startup, enabling efficient cross-module filtering +- **Data Freshness:** Data is synced automatically, but there may be a brief delay after mutations +- **Fallback:** If you don't need filtering, `query.graph()` is sufficient and more straightforward +- **Module Relations:** Always use `query.graph()` for same-module relations (product → variants, order → line items) + +## Validation with throwIfKeyNotFound + +Use `throwIfKeyNotFound` to validate that a record exists before performing operations: + +```typescript +// Outside workflows +const query = req.scope.resolve("query") + +const { data } = await query.graph({ + entity: "product", + fields: ["id", "title"], + filters: { + id: productId, + }, +}, { + throwIfKeyNotFound: true, // Throws if product doesn't exist +}) + +// If we get here, product exists +const product = data[0] +``` + +```typescript +// In workflows +const { data: products } = useQueryGraphStep({ + entity: "product", + fields: ["id", "title"], + filters: { + id: input.product_id, + }, + options: { + throwIfKeyNotFound: true, // Throws if product doesn't exist + }, +}) +``` + +**When to use:** +- ✅ Before updating or deleting a record +- ✅ When the record MUST exist for the operation to continue +- ✅ To avoid manual existence checks + +```typescript +// ❌ BAD: Manual check +const { data } = await query.graph({ /* ... */ }) +if (!data || data.length === 0) { + throw new MedusaError(MedusaError.Types.NOT_FOUND, "Product not found") +} + +// ✅ GOOD: Let query handle it +const { data } = await query.graph( + { /* ... */ }, + { throwIfKeyNotFound: true } +) +``` + +## Performance Best Practices + +### 1. Only Query What You Need + +**⚠️ CRITICAL**: Always specify only the fields you'll use. Avoid using `*` or querying unnecessary relations. + +```typescript +// ❌ BAD: Retrieves everything (slow, wasteful) +fields: ["*"] + +// ✅ GOOD: Only needed fields (fast) +fields: ["id", "title", "price"] +``` + +### 2. Limit Relation Depth + +There's no hard limit on relation depth, but deeper queries are slower. Only include relations you'll actually use. + +```typescript +// ❌ BAD: Unnecessary depth +fields: [ + "id", + "title", + "variants.*", + "variants.product.*", // Circular, unnecessary + "variants.prices.*", + "variants.prices.currency.*", // Probably don't need all currency fields +] + +// ✅ GOOD: Appropriate depth +fields: [ + "id", + "title", + "variants.id", + "variants.sku", + "variants.prices.amount", + "variants.prices.currency_code", +] +``` + +### 3. Use Pagination for Large Result Sets + +```typescript +// ✅ GOOD: Paginated query +const { data, metadata } = await query.graph({ + entity: "product", + fields: ["id", "title"], + pagination: { + take: 50, // Don't retrieve thousands of records at once + skip: 0, + }, +}) +``` + +### 4. Filter Early + +Apply filters to reduce the data set before retrieving fields and relations: + +```typescript +// ✅ GOOD: Filters reduce result set first +const { data } = await query.graph({ + entity: "product", + fields: ["id", "title", "variants.*"], + filters: { + status: "published", + created_at: { + $gte: lastWeek, + }, + }, +}) +``` + +### 5. Use Specific Queries for Different Use Cases + +```typescript +// ✅ For listings (minimal fields) +const { data: listings } = await query.graph({ + entity: "product", + fields: ["id", "title", "thumbnail", "price"], +}) + +// ✅ For detail pages (more fields) +const { data: details } = await query.graph({ + entity: "product", + fields: [ + "id", + "title", + "description", + "thumbnail", + "images.*", + "variants.*", + "variants.prices.*", + ], + filters: { id: productId }, +}) +``` + +## Common Patterns + +### Pattern: List with Search + +```typescript +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const query = req.scope.resolve("query") + const { q } = req.validatedQuery + + const filters: any = {} + if (q) { + filters.title = { $like: `%${q}%` } + } + + const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "title", "thumbnail"], + filters, + ...req.queryConfig, // Uses request query config + }) + + return res.json({ products }) +} +``` + +### Pattern: Retrieve with Validation + +```typescript +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const query = req.scope.resolve("query") + const { id } = req.params + + // Throws 404 if product doesn't exist + const { data } = await query.graph({ + entity: "product", + fields: ["id", "title", "description", "variants.*"], + filters: { id }, + }, { + throwIfKeyNotFound: true, + }) + + return res.json({ product: data[0] }) +} +``` + +### Pattern: Query with Relations and Filters + +```typescript +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const query = req.scope.resolve("query") + const { category_id } = req.validatedQuery + + const { data: products } = await query.graph({ + entity: "product", + fields: [ + "id", + "title", + "thumbnail", + "variants.id", + "variants.prices.amount", + "category.name", + ], + filters: { + category_id, + status: "published", + }, + pagination: { + take: 20, + skip: 0, + }, + }) + + return res.json({ products }) +} +``` + +### Pattern: Count Records + +```typescript +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const query = req.scope.resolve("query") + + const { data, metadata } = await query.graph({ + entity: "product", + fields: ["id"], // Minimal fields for counting + filters: { + status: "published", + }, + }) + + return res.json({ + count: metadata.count, + }) +} +``` + +### Pattern: Recent Items + +```typescript +export async function GET(req: MedusaRequest, res: MedusaResponse) { + const query = req.scope.resolve("query") + + const { data: recentProducts } = await query.graph({ + entity: "product", + fields: ["id", "title", "created_at"], + pagination: { + take: 10, + skip: 0, + order: { + created_at: "DESC", // Newest first + }, + }, + }) + + return res.json({ products: recentProducts }) +} +``` diff --git a/.agents/skills/building-with-medusa/reference/scheduled-jobs.md b/.agents/skills/building-with-medusa/reference/scheduled-jobs.md new file mode 100644 index 0000000..7c2718f --- /dev/null +++ b/.agents/skills/building-with-medusa/reference/scheduled-jobs.md @@ -0,0 +1,417 @@ +# Scheduled Jobs + +Scheduled jobs are asynchronous functions that run automatically at specified intervals during the Medusa application's runtime. Use them for tasks like syncing products to third-party services, sending periodic reports, or cleaning up stale data. + +## Contents +- [When to Use Scheduled Jobs](#when-to-use-scheduled-jobs) +- [Creating a Scheduled Job](#creating-a-scheduled-job) +- [Configuration Options](#configuration-options) +- [Executing Workflows in Scheduled Jobs](#executing-workflows-in-scheduled-jobs) +- [Cron Expression Examples](#cron-expression-examples) +- [Best Practices](#best-practices) + +## When to Use Scheduled Jobs + +Use scheduled jobs when you need to perform actions **periodically**: + +- ✅ Syncing data with third-party services on a schedule +- ✅ Sending periodic reports (daily, weekly) +- ✅ Cleaning up stale data (expired carts, old sessions) +- ✅ Generating batch exports +- ✅ Recalculating aggregated data + +**Don't use scheduled jobs for:** +- ❌ Reacting to events (use [subscribers](subscribers-and-events.md) instead) +- ❌ One-time tasks (use workflows directly) +- ❌ Real-time processing (use API routes + workflows) + +**Scheduled Jobs vs Subscribers:** +- **Scheduled Job**: Finds carts updated >24h ago and sends emails (polling pattern) +- **Subscriber**: Reacts to `order.created` and sends an email (event-driven) + +For most use cases, subscribers are preferred when you need to react to specific events. + +## Creating a Scheduled Job + +Create a TypeScript file in the `src/jobs/` directory: + +```typescript +// src/jobs/sync-products.ts +import { MedusaContainer } from "@medusajs/framework/types" + +export default async function syncProductsJob(container: MedusaContainer) { + const logger = container.resolve("logger") + + logger.info("Starting product sync...") + + // Resolve services from container + const productService = container.resolve("product") + const myService = container.resolve("my-custom-service") + + try { + // Your job logic here + const products = await productService.listProducts({ active: true }) + + for (const product of products) { + // Process each product + await myService.syncToExternalSystem(product) + } + + logger.info("Product sync completed successfully") + } catch (error) { + logger.error(`Product sync failed: ${error.message}`) + // Don't throw - let the job complete and retry on next schedule + } +} + +export const config = { + name: "sync-products-daily", // Unique name for the job + schedule: "0 0 * * *", // Cron expression: midnight daily +} +``` + +## Configuration Options + +```typescript +export const config = { + name: "my-job", // Required: unique identifier + schedule: "* * * * *", // Required: cron expression + numberOfExecutions: 3, // Optional: limit total scheduled executions +} +``` + +### Configuration Properties + +- **name** (required): Unique identifier for the job across your application +- **schedule** (required): Cron expression defining when to run +- **numberOfExecutions** (optional): Maximum number of times to execute the job **according to its schedule** + +**⚠️ CRITICAL - Understanding numberOfExecutions:** + +`numberOfExecutions` limits how many times the job runs **on its schedule**, NOT immediately on server start. + +```typescript +// ❌ WRONG UNDERSTANDING: This will NOT run immediately on server start +export const config = { + name: "test-job", + schedule: "0 0 * * *", // Daily at midnight + numberOfExecutions: 1, // Will run ONCE at the next midnight, not now! +} + +// ✅ CORRECT: To test a job immediately, use a frequent schedule +export const config = { + name: "test-job", + schedule: "* * * * *", // Every minute + numberOfExecutions: 1, // Will run once at the next minute +} + +// ✅ CORRECT: Testing with multiple runs +export const config = { + name: "test-job", + schedule: "*/5 * * * *", // Every 5 minutes + numberOfExecutions: 3, // Will run 3 times (at 0, 5, 10 minutes), then stop +} +``` + +**Key points:** +- The job waits for the first scheduled time before executing +- `numberOfExecutions: 1` with a daily schedule means it runs once the next day +- To test immediately, use a frequent schedule like `"* * * * *"` (every minute) +- After reaching `numberOfExecutions`, the job stops running permanently + +## Executing Workflows in Scheduled Jobs + +**⚠️ BEST PRACTICE**: Use workflows for mutations in scheduled jobs. This ensures proper error handling and rollback capabilities. + +```typescript +// src/jobs/send-weekly-newsletter.ts +import { MedusaContainer } from "@medusajs/framework/types" +import { sendNewsletterWorkflow } from "../workflows/send-newsletter" + +export default async function sendNewsletterJob(container: MedusaContainer) { + const logger = container.resolve("logger") + const query = container.resolve("query") + + logger.info("Sending weekly newsletter...") + + try { + // Query for data + const { data: customers } = await query.graph({ + entity: "customer", + fields: ["id", "email"], + filters: { + newsletter_subscribed: true, + }, + }) + + logger.info(`Found ${customers.length} subscribers`) + + // Execute workflow + await sendNewsletterWorkflow(container).run({ + input: { + customer_ids: customers.map((c) => c.id), + }, + }) + + logger.info("Newsletter sent successfully") + } catch (error) { + logger.error(`Newsletter job failed: ${error.message}`) + } +} + +export const config = { + name: "send-weekly-newsletter", + schedule: "0 0 * * 0", // Every Sunday at midnight +} +``` + +## Cron Expression Examples + +Cron format: `minute hour day-of-month month day-of-week` + +```typescript +// Every minute +schedule: "* * * * *" + +// Every 5 minutes +schedule: "*/5 * * * *" + +// Every hour at minute 0 +schedule: "0 * * * *" + +// Every day at midnight (00:00) +schedule: "0 0 * * *" + +// Every day at 2:30 AM +schedule: "30 2 * * *" + +// Every Sunday at midnight +schedule: "0 0 * * 0" + +// Every Monday at 9 AM +schedule: "0 9 * * 1" + +// First day of every month at midnight +schedule: "0 0 1 * *" + +// Every weekday (Mon-Fri) at 6 PM +schedule: "0 18 * * 1-5" + +// Every 6 hours +schedule: "0 */6 * * *" +``` + +**Tip**: Use [crontab.guru](https://crontab.guru) to build and validate cron expressions. + +## Best Practices + +### 1. Always Use Logging + +```typescript +export default async function myJob(container: MedusaContainer) { + const logger = container.resolve("logger") + + logger.info("Job started") + + try { + // Job logic + logger.info("Job completed successfully") + } catch (error) { + logger.error(`Job failed: ${error.message}`, { error }) + } +} +``` + +### 2. Handle Errors Gracefully + +Don't throw errors at the top level - log them and let the job complete: + +```typescript +// ❌ BAD: Throws and stops execution +export default async function myJob(container: MedusaContainer) { + const service = container.resolve("my-service") + const items = await service.getItems() // Might throw + // Job stops if this throws +} + +// ✅ GOOD: Catches errors and logs +export default async function myJob(container: MedusaContainer) { + const logger = container.resolve("logger") + + try { + const service = container.resolve("my-service") + const items = await service.getItems() + // Process items + } catch (error) { + logger.error(`Job failed: ${error.message}`) + // Job completes, will retry on next schedule + } +} +``` + +### 3. Make Jobs Idempotent + +Design jobs to be safely re-runnable: + +```typescript +// ✅ GOOD: Idempotent job +export default async function syncProducts(container: MedusaContainer) { + const logger = container.resolve("logger") + const myService = container.resolve("my-service") + + // Check what's already synced + const lastSyncTime = await myService.getLastSyncTime() + + // Only sync products updated since last sync + const { data: products } = await query.graph({ + entity: "product", + filters: { + updated_at: { $gte: lastSyncTime }, + }, + }) + + // Sync products (upsert, don't insert) + for (const product of products) { + await myService.upsertToExternalSystem(product) + } + + // Update last sync time + await myService.setLastSyncTime(new Date()) +} +``` + +### 4. Use Workflows for Mutations + +```typescript +// ✅ GOOD: Uses workflow for mutations +import { deleteCartsWorkflow } from "../workflows/delete-carts" + +export default async function cleanupExpiredCarts(container: MedusaContainer) { + const logger = container.resolve("logger") + const query = container.resolve("query") + + // Find expired carts + const { data: carts } = await query.graph({ + entity: "cart", + fields: ["id"], + filters: { + updated_at: { + $lte: new Date(Date.now() - 24 * 60 * 60 * 1000), // 24 hours ago + }, + }, + }) + + logger.info(`Found ${carts.length} expired carts`) + + // Use workflow for deletion (import at top of file) + await deleteCartsWorkflow(container).run({ + input: { + cart_ids: carts.map((c) => c.id), + }, + }) + + logger.info("Expired carts cleaned up") +} +``` + +### 5. Add Metrics/Monitoring + +```typescript +export default async function myJob(container: MedusaContainer) { + const logger = container.resolve("logger") + const startTime = Date.now() + + try { + // Job logic + const processed = 100 // Track what you processed + + const duration = Date.now() - startTime + logger.info(`Job completed: ${processed} items in ${duration}ms`) + } catch (error) { + logger.error(`Job failed after ${Date.now() - startTime}ms`) + } +} +``` + +### 6. Test with Limited Executions + +When testing, use a frequent schedule with limited executions: + +```typescript +// ✅ CORRECT: Frequent schedule for immediate testing +export const config = { + name: "test-job", + schedule: "* * * * *", // Every minute + numberOfExecutions: 3, // Run 3 times (next 3 minutes), then stop +} + +// ❌ WRONG: This won't help with testing +export const config = { + name: "test-job", + schedule: "0 0 * * *", // Daily at midnight + numberOfExecutions: 1, // Will only run ONCE at next midnight, not useful for testing +} +``` + +**Remember**: `numberOfExecutions` doesn't make the job run immediately - it limits how many times it runs on its schedule. + +## Complete Example: Abandoned Cart Email Job + +```typescript +// src/jobs/send-abandoned-cart-emails.ts +import { MedusaContainer } from "@medusajs/framework/types" +import { sendAbandonedCartEmailWorkflow } from "../workflows/send-abandoned-cart-email" + +export default async function abandonedCartEmailJob( + container: MedusaContainer +) { + const logger = container.resolve("logger") + const query = container.resolve("query") + + logger.info("Starting abandoned cart email job...") + + try { + // Find carts updated more than 24 hours ago that haven't completed + const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000) + + const { data: carts } = await query.graph({ + entity: "cart", + fields: ["id", "email", "customer_id"], + filters: { + updated_at: { + $lte: twentyFourHoursAgo, + }, + completed_at: null, + email: { $ne: null }, // Must have email + }, + }) + + logger.info(`Found ${carts.length} abandoned carts`) + + // Process in batches + for (const cart of carts) { + try { + await sendAbandonedCartEmailWorkflow(container).run({ + input: { + cart_id: cart.id, + email: cart.email, + }, + }) + logger.info(`Sent email for cart ${cart.id}`) + } catch (error) { + logger.error(`Failed to send email for cart ${cart.id}: ${error.message}`) + // Continue with other carts + } + } + + logger.info("Abandoned cart email job completed") + } catch (error) { + logger.error(`Abandoned cart job failed: ${error.message}`) + } +} + +export const config = { + name: "send-abandoned-cart-emails", + schedule: "0 */6 * * *", // Every 6 hours +} +``` diff --git a/.agents/skills/building-with-medusa/reference/subscribers-and-events.md b/.agents/skills/building-with-medusa/reference/subscribers-and-events.md new file mode 100644 index 0000000..bc46dc0 --- /dev/null +++ b/.agents/skills/building-with-medusa/reference/subscribers-and-events.md @@ -0,0 +1,544 @@ +# Subscribers and Events + +Subscribers are asynchronous functions that execute when specific events are emitted. Use them to perform actions after commerce operations, like sending confirmation emails when an order is placed. + +## Contents +- [When to Use Subscribers](#when-to-use-subscribers) +- [Creating a Subscriber](#creating-a-subscriber) +- [Common Commerce Events](#common-commerce-events) +- [Accessing Event Data](#accessing-event-data) +- [Triggering Custom Events](#triggering-custom-events) +- [Best Practices](#best-practices) + +## When to Use Subscribers + +Use subscribers when you need to **react to events** that happen in your application: + +- ✅ Send confirmation emails when orders are placed +- ✅ Sync data to external systems when products are updated +- ✅ Trigger webhooks when entities change +- ✅ Update analytics when customers are created +- ✅ Perform non-blocking side effects + +**Don't use subscribers for:** +- ❌ Periodic tasks (use [scheduled jobs](scheduled-jobs.md) instead) +- ❌ Operations that must block the main flow (use workflows instead) +- ❌ Scheduling future tasks (subscribers execute immediately) + +**Subscribers vs Scheduled Jobs:** +- **Subscriber**: Reacts to `order.placed` event and sends confirmation email (event-driven) +- **Scheduled Job**: Finds abandoned carts every 6 hours and sends emails (polling pattern) + +## Creating a Subscriber + +Create a TypeScript file in the `src/subscribers/` directory: + +```typescript +// src/subscribers/order-placed.ts +import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework" + +export default async function orderPlacedHandler({ + event: { eventName, data }, + container, +}: SubscriberArgs<{ id: string }>) { + const logger = container.resolve("logger") + + logger.info(`Order ${data.id} was placed`) + + // Resolve services + const orderService = container.resolve("order") + const notificationService = container.resolve("notification") + + // Retrieve full order data + const order = await orderService.retrieveOrder(data.id, { + relations: ["customer", "items"], + }) + + // Send confirmation email + await notificationService.createNotifications({ + to: order.customer.email, + template: "order-confirmation", + channel: "email", + data: { order }, + }) + + logger.info(`Confirmation email sent for order ${data.id}`) +} + +export const config: SubscriberConfig = { + event: "order.placed", // Single event +} +``` + +### Listening to Multiple Events + +```typescript +// src/subscribers/product-changes.ts +import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework" + +export default async function productChangesHandler({ + event: { eventName, data }, + container, +}: SubscriberArgs<{ id: string }>) { + const logger = container.resolve("logger") + + logger.info(`Product event: ${eventName} for product ${data.id}`) + + // Handle different events + switch (eventName) { + case "product.created": + // Handle product creation + break + case "product.updated": + // Handle product update + break + case "product.deleted": + // Handle product deletion + break + } +} + +export const config: SubscriberConfig = { + event: ["product.created", "product.updated", "product.deleted"], +} +``` + +## Common Commerce Events + +**⚠️ IMPORTANT**: Event data typically contains only the ID of the affected entity. You must retrieve the full data if needed. + +### Order Events + +```typescript +"order.placed" // Order was placed +"order.updated" // Order was updated +"order.canceled" // Order was canceled +"order.completed" // Order was completed +"order.shipment_created" // Shipment was created for order +``` + +### Product Events + +```typescript +"product.created" // Product was created +"product.updated" // Product was updated +"product.deleted" // Product was deleted +``` + +### Customer Events + +```typescript +"customer.created" // Customer was created +"customer.updated" // Customer was updated +``` + +### Cart Events + +```typescript +"cart.created" // Cart was created +"cart.updated" // Cart was updated +``` + +### Auth Events + +```typescript +"auth.password_reset" // Password reset was requested +``` + +### Invite Events + +```typescript +"invite.created" // Invite was created (for admin users) +``` + +**For a complete list of events**, ask MedusaDocs for the specific module's events. + +## Accessing Event Data + +### Event Data Structure + +```typescript +interface SubscriberArgs { + event: { + eventName: string // e.g., "order.placed" + data: T // Event payload (usually contains { id: string }) + } + container: MedusaContainer // DI container +} +``` + +### Retrieving Full Entity Data + +**⚠️ IMPORTANT**: The `data` object typically only contains the entity ID. Retrieve the full entity data using services or query: + +```typescript +// src/subscribers/order-placed.ts +import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework" + +export default async function orderPlacedHandler({ + event: { data }, + container, +}: SubscriberArgs<{ id: string }>) { + const logger = container.resolve("logger") + const query = container.resolve("query") + + // data.id contains the order ID + logger.info(`Handling order.placed event for order: ${data.id}`) + + // Retrieve full order data with relations + const { data: orders } = await query.graph({ + entity: "order", + fields: [ + "id", + "email", + "total", + "customer.*", + "items.*", + "items.product.*", + ], + filters: { + id: data.id, + }, + }) + + const order = orders[0] + + // Now you have the full order data + logger.info(`Order total: ${order.total}`) + logger.info(`Customer email: ${order.customer.email}`) +} + +export const config: SubscriberConfig = { + event: "order.placed", +} +``` + +### Using Module Services + +```typescript +export default async function productUpdatedHandler({ + event: { data }, + container, +}: SubscriberArgs<{ id: string }>) { + const productService = container.resolve("product") + + // Retrieve product using service + const product = await productService.retrieveProduct(data.id, { + select: ["id", "title", "status"], + relations: ["variants"], + }) + + // Process product +} +``` + +## Triggering Custom Events + +Emit custom events from workflows using the `emitEventStep`: + +```typescript +// src/workflows/create-review.ts +import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { emitEventStep } from "@medusajs/medusa/core-flows" + +const createReviewWorkflow = createWorkflow( + "create-review", + function (input: { product_id: string; rating: number }) { + // Create review step + const review = createReviewStep(input) + + // Emit custom event + emitEventStep({ + eventName: "review.created", + data: { + id: review.id, + product_id: input.product_id, + rating: input.rating, + }, + }) + + return new WorkflowResponse({ review }) + } +) + +export default createReviewWorkflow +``` + +Then create a subscriber for the custom event: + +```typescript +// src/subscribers/review-created.ts +import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework" + +export default async function reviewCreatedHandler({ + event: { data }, + container, +}: SubscriberArgs<{ id: string; product_id: string; rating: number }>) { + const logger = container.resolve("logger") + const query = container.resolve("query") + + logger.info(`Review ${data.id} created for product ${data.product_id}`) + + // If rating is low, notify support + if (data.rating <= 2) { + const notificationService = container.resolve("notification") + await notificationService.createNotifications({ + to: "support@example.com", + template: "low-rating-alert", + channel: "email", + data: { + review_id: data.id, + product_id: data.product_id, + rating: data.rating, + }, + }) + } +} + +export const config: SubscriberConfig = { + event: "review.created", +} +``` + +## Best Practices + +### 1. Always Use Logging + +```typescript +export default async function mySubscriber({ + event: { eventName, data }, + container, +}: SubscriberArgs<{ id: string }>) { + const logger = container.resolve("logger") + + logger.info(`Handling ${eventName} for ${data.id}`) + + try { + // Subscriber logic + logger.info(`Successfully handled ${eventName}`) + } catch (error) { + logger.error(`Failed to handle ${eventName}: ${error.message}`) + } +} +``` + +### 2. Handle Errors Gracefully + +Subscribers run asynchronously and don't block the main flow. Log errors but don't throw: + +```typescript +// ✅ GOOD: Catches errors and logs +export default async function mySubscriber({ + event: { data }, + container, +}: SubscriberArgs<{ id: string }>) { + const logger = container.resolve("logger") + + try { + // Subscriber logic that might fail + await sendEmail(data.id) + } catch (error) { + logger.error(`Failed to send email: ${error.message}`) + // Don't throw - subscriber completes gracefully + } +} +``` + +### 3. Keep Subscribers Fast and Non-Blocking + +Subscribers should perform quick operations. For long-running tasks, consider: +- Queuing the task for background processing +- Using scheduled jobs instead +- Breaking the work into smaller steps + +```typescript +// ✅ GOOD: Quick operation +export default async function orderPlacedHandler({ + event: { data }, + container, +}: SubscriberArgs<{ id: string }>) { + const notificationService = container.resolve("notification") + + // Quick: Queue email for sending + await notificationService.createNotifications({ + to: "customer@example.com", + template: "order-confirmation", + channel: "email", + data: { order_id: data.id }, + }) +} +``` + +### 4. Use Workflows for Mutations + +If your subscriber needs to perform mutations, use workflows: + +```typescript +// ✅ GOOD: Uses workflow for mutations +import { syncProductWorkflow } from "../workflows/sync-product" + +export default async function productCreatedHandler({ + event: { data }, + container, +}: SubscriberArgs<{ id: string }>) { + const logger = container.resolve("logger") + + // Execute workflow to sync to external system + try { + await syncProductWorkflow(container).run({ + input: { product_id: data.id }, + }) + logger.info(`Product ${data.id} synced successfully`) + } catch (error) { + logger.error(`Failed to sync product ${data.id}: ${error.message}`) + } +} +``` + +### 5. Avoid Infinite Event Loops + +Be careful when subscribing to events that trigger more events: + +```typescript +// ❌ BAD: Can cause infinite loop +export default async function productUpdatedHandler({ + event: { data }, + container, +}: SubscriberArgs<{ id: string }>) { + const productService = container.resolve("product") + + // This triggers another product.updated event! + await productService.updateProducts({ + id: data.id, + metadata: { last_updated: new Date() }, + }) +} + +// ✅ GOOD: Add guard condition +export default async function productUpdatedHandler({ + event: { data }, + container, +}: SubscriberArgs<{ id: string }>) { + const logger = container.resolve("logger") + const query = container.resolve("query") + + // Retrieve product to check if we should update + const { data: products } = await query.graph({ + entity: "product", + fields: ["id", "metadata"], + filters: { id: data.id }, + }) + + const product = products[0] + + // Guard: Only update if not already processed + if (!product.metadata?.processed) { + const productService = container.resolve("product") + await productService.updateProducts({ + id: data.id, + metadata: { processed: true }, + }) + } +} +``` + +### 6. Make Subscribers Idempotent + +Subscribers might be called multiple times for the same event. Design them to handle this: + +```typescript +export default async function orderPlacedHandler({ + event: { data }, + container, +}: SubscriberArgs<{ id: string }>) { + const logger = container.resolve("logger") + const myService = container.resolve("my-service") + + // Check if we've already processed this order + const processed = await myService.isOrderProcessed(data.id) + + if (processed) { + logger.info(`Order ${data.id} already processed, skipping`) + return + } + + // Process order + await myService.processOrder(data.id) + + // Mark as processed + await myService.markOrderAsProcessed(data.id) +} +``` + +## Complete Example: Order Confirmation Email + +```typescript +// src/subscribers/order-placed.ts +import { SubscriberArgs, type SubscriberConfig } from "@medusajs/framework" + +export default async function sendOrderConfirmationEmail({ + event: { data }, + container, +}: SubscriberArgs<{ id: string }>) { + const logger = container.resolve("logger") + + logger.info(`Sending order confirmation for order: ${data.id}`) + + try { + const query = container.resolve("query") + + // Retrieve full order data + const { data: orders } = await query.graph({ + entity: "order", + fields: [ + "id", + "display_id", + "email", + "total", + "currency_code", + "customer.first_name", + "customer.last_name", + "items.*", + "items.product.title", + "shipping_address.*", + ], + filters: { + id: data.id, + }, + }) + + if (!orders || orders.length === 0) { + logger.error(`Order ${data.id} not found`) + return + } + + const order = orders[0] + + // Send confirmation email + const notificationService = container.resolve("notification") + await notificationService.createNotifications({ + to: order.email, + template: "order-confirmation", + channel: "email", + data: { + order_id: order.display_id, + customer_name: `${order.customer.first_name} ${order.customer.last_name}`, + items: order.items, + total: order.total, + currency: order.currency_code, + shipping_address: order.shipping_address, + }, + }) + + logger.info(`Order confirmation email sent to ${order.email}`) + } catch (error) { + logger.error( + `Failed to send order confirmation for ${data.id}: ${error.message}` + ) + } +} + +export const config: SubscriberConfig = { + event: "order.placed", +} +``` \ No newline at end of file diff --git a/.agents/skills/building-with-medusa/reference/troubleshooting.md b/.agents/skills/building-with-medusa/reference/troubleshooting.md new file mode 100644 index 0000000..5bdc044 --- /dev/null +++ b/.agents/skills/building-with-medusa/reference/troubleshooting.md @@ -0,0 +1,225 @@ +# Troubleshooting Common Medusa Backend Issues + +This guide covers common errors and their solutions when building with Medusa. + +## Contents +- [Module Registration Errors](#module-registration-errors) +- [API Route Errors](#api-route-errors) +- [Authentication Errors](#authentication-errors) +- [General Debugging Tips](#general-debugging-tips) + +## Module Registration Errors + +### Error: Module "X" not registered + +``` +Error: Module "my-module" is not registered in the container +``` + +**Cause**: Module not added to `medusa-config.ts` or server not restarted. + +**Solution**: +1. Add module to `medusa-config.ts`: +```typescript +module.exports = defineConfig({ + modules: [ + { resolve: "./src/modules/my-module" } + ], +}) +``` +2. Restart the Medusa server + +### Error: Cannot find module './modules/X' + +``` +Error: Cannot find module './modules/my-module' +``` + +**Cause**: Module path is incorrect or module structure is incomplete. + +**Solution**: +1. Verify module structure: +``` +src/modules/my-module/ +├── models/ +│ └── my-model.ts +├── service.ts +└── index.ts +``` +2. Ensure `index.ts` exports the module correctly +3. Check path in `medusa-config.ts` matches actual directory + +## API Route Errors + +### Error: validatedBody is undefined + +``` +TypeError: Cannot read property 'email' of undefined +``` + +**Cause**: Forgot to add validation middleware or accessing `req.validatedBody` instead of `req.body`. + +**Solution**: +1. Add validation middleware: +```typescript +// middlewares.ts +export const myMiddlewares: MiddlewareRoute[] = [ + { + matcher: "/store/my-route", + method: "POST", + middlewares: [validateAndTransformBody(MySchema)], + }, +] +``` +2. Access `req.validatedBody` not `req.body` + +### Error: queryConfig is undefined + +``` +TypeError: Cannot spread undefined +``` + +**Cause**: Using `...req.queryConfig` without setting up query config middleware. + +**Solution**: +Add `validateAndTransformQuery` middleware: +```typescript +import { createFindParams } from "@medusajs/medusa/api/utils/validators" + +export const GetMyItemsSchema = createFindParams() + +export default defineMiddlewares({ + routes: [ + { + matcher: "/store/my-items", + method: "GET", + middlewares: [ + validateAndTransformQuery(GetMyItemsSchema, { + defaults: ["id", "name"], + isList: true, + }), + ], + }, + ], +}) +``` + +### Error: MedusaError not being formatted + +``` +Error: [object Object] +``` + +**Cause**: Throwing regular `Error` instead of `MedusaError`. + +**Solution**: +```typescript +// ❌ WRONG +throw new Error("Not found") + +// ✅ CORRECT +import { MedusaError } from "@medusajs/framework/utils" +throw new MedusaError(MedusaError.Types.NOT_FOUND, "Not found") +``` + +### Error: Middleware not applying + +``` +Error: Route is not being validated +``` + +**Cause**: Middleware matcher doesn't match route path or middleware not registered. + +**Solution**: +1. Check matcher pattern matches your route: +```typescript +// For route: /store/my-route +matcher: "/store/my-route" // Exact match + +// For multiple routes: /store/my-route, /store/my-route/123 +matcher: "/store/my-route*" // Wildcard +``` +2. Ensure middleware is exported and registered in `api/middlewares.ts` + +## Authentication Errors + +### Error: auth_context is undefined + +``` +TypeError: Cannot read property 'actor_id' of undefined +``` + +**Cause**: Route is not protected or user is not authenticated. + +**Solution**: +1. Check if route is under protected prefix (`/admin/*` or `/store/customers/me/*`) +2. If custom prefix, add authentication middleware: +```typescript +export default defineMiddlewares({ + routes: [ + { + matcher: "/custom/admin*", + middlewares: [authenticate("user", ["session", "bearer", "api-key"])], + }, + ], +}) +``` +3. For optional auth, check if `auth_context` exists: +```typescript +const userId = req.auth_context?.actor_id +if (!userId) { + // Handle unauthenticated case +} +``` + +## General Debugging Tips + +### Enable Debug Logging + +```bash +# Set log level to debug +LOG_LEVEL=debug npx medusa develop +``` + +### Log Values In Workflows with Transform + +```typescript +import { + createStep, + createWorkflow, + StepResponse, + WorkflowResponse, + transform, +} from "@medusajs/framework/workflows-sdk" + +const step1 = createStep( + "step-1", + async () => { + const message = "Hello from step 1!" + + return new StepResponse( + message + ) + } +) + +export const myWorkflow = createWorkflow( + "my-workflow", + () => { + const response = step1() + + const transformedMessage = transform( + { response }, + (data) => { + const upperCase = data.response.toUpperCase() + console.log("Transformed Data:", upperCase) + return upperCase + } + ) + + return new WorkflowResponse({ + response: transformedMessage, + }) + } +) +``` \ No newline at end of file diff --git a/.agents/skills/building-with-medusa/reference/workflow-hooks.md b/.agents/skills/building-with-medusa/reference/workflow-hooks.md new file mode 100644 index 0000000..2ce2002 --- /dev/null +++ b/.agents/skills/building-with-medusa/reference/workflow-hooks.md @@ -0,0 +1,63 @@ +# Workflow Hooks (Advanced) + +Workflow hooks let you inject custom logic into existing Medusa workflows without recreating them. Use them to extend core commerce flows. + +**Note:** Hooks run in-band (synchronously within the workflow). If your task can run in the background, use a subscriber instead for better performance. + +## Basic Hook Pattern + +```typescript +// src/workflows/hooks/product-created.ts +import { createProductsWorkflow } from "@medusajs/medusa/core-flows" +import { StepResponse } from "@medusajs/framework/workflows-sdk" + +createProductsWorkflow.hooks.productsCreated( + // Hook handler + async ({ products, additional_data }, { container }) => { + if (!additional_data?.brand_id) { + return new StepResponse([], []) + } + + const link = container.resolve("link") + + // Link products to brand + const linkData = products.map((product) => ({ + product: { product_id: product.id }, + brand: { brand_id: additional_data.brand_id }, + })) + + await link.create(linkData) + return new StepResponse(linkData, linkData) + }, + // Compensation (runs if workflow fails after this point) + async (linkData, { container }) => { + const link = container.resolve("link") + await link.dismiss(linkData) + } +) +``` + +## Common Workflow Hooks + +- `createProductsWorkflow.hooks.productsCreated` - After products are created +- `createOrderWorkflow.hooks.orderCreated` - After an order is created +- Ask MedusaDocs for specific workflow hooks and their input parameters + +## When to Use Hooks vs Subscribers + +**Use workflow hooks when:** +- The logic must complete before the workflow finishes +- You need rollback/compensation capabilities +- The operation is critical to the workflow's success + +**Use subscribers when:** +- The logic can run asynchronously in the background +- You don't need to block the main workflow +- Better performance is needed (hooks are synchronous) + +## Hook Best Practices + +1. **Return StepResponse**: Always wrap your return value +2. **Implement compensation**: Provide rollback logic for the compensation function +3. **Handle missing data gracefully**: Check for optional data and return early if not present +4. **Keep hooks lightweight**: For heavy operations, consider using subscribers instead diff --git a/.agents/skills/building-with-medusa/reference/workflows.md b/.agents/skills/building-with-medusa/reference/workflows.md new file mode 100644 index 0000000..535bb30 --- /dev/null +++ b/.agents/skills/building-with-medusa/reference/workflows.md @@ -0,0 +1,516 @@ +# Creating Workflows + +Workflows are the standard way to perform mutations (create, update, delete) in modules in Medusa. If you have built a custom module and need to perform mutations on models in the module, you should create a workflow. + +## Creating Workflows - Implementation Checklist + +**IMPORTANT FOR CLAUDE CODE**: When implementing workflows, use the TodoWrite tool to track your progress through these steps. This ensures you don't miss any critical steps and provides visibility to the user. + +Create these tasks in your todo list: + +- Define the input type for your workflow +- Create step function (one mutation per step) +- Add compensation function to steps for rollback +- Create workflow composition function +- Follow workflow composition rules (no async, no arrow functions, etc.) +- Return WorkflowResponse with results +- Test idempotency (workflow can be retried safely) +- **CRITICAL: Run build to validate implementation** (catches type errors and issues) + +## Basic Workflow Structure + +**File Organization:** +- **Recommended**: Create workflow steps in `src/workflows/steps/[step-name].ts` +- Workflow composition functions go in `src/workflows/[workflow-name].ts` +- This keeps steps reusable and organized + +```typescript +// src/workflows/steps/create-my-model.ts +import { createStep, StepResponse } from "@medusajs/framework/workflows-sdk" + +type Input = { + my_key: string +} + +// Note: a step should only do one mutation this ensures rollback mechanisms work +// For workflows that retry build your steps to be idempotent +export const createMyModelStep = createStep( + "create-my-model", + async (input: Input, { container }) => { + const myModule = container.resolve("my") + + const [newMy] = await myModule.createMyModels({ + ...input, + }) + + return new StepResponse( + newMy, + newMy.id // explicit compensation input - otherwise defaults to step's output + ) + }, + // Optional compensation function + async (id, { container }) => { + const myModule = container.resolve("my") + await myModule.deleteMyModels(id) + } +) + +// src/workflows/create-my-model.ts +import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { createMyModelStep } from "./steps/create-my-model" + +type Input = { + my_key: string +} + +const createMyModel = createWorkflow( + "create-my-model", + // Note: See "Workflow Composition Rules" section below for important constraints + // The workflow function must be a regular synchronous function (not async/arrow) + // No direct variable manipulation, conditionals, or date creation - use transform/when instead + function (input: Input) { + const newMy = createMyModelStep(input) + + return new WorkflowResponse({ + newMy, + }) + } +) + +export default createMyModel +``` + +## Workflow Composition Rules + +The workflow composition function runs at application load time and has important limitations: + +### Function Declaration +- ✅ Use regular synchronous functions +- ❌ No `async` functions +- ❌ No arrow functions (use `function` keyword) + +### Using Steps Multiple Times + +**⚠️ CRITICAL**: When using the same step multiple times in a workflow, you MUST rename each invocation AFTER the first invocation using `.config()` to avoid conflicts. + +```typescript +// ✅ CORRECT - Rename each step invocation with .config() +export const processCustomersWorkflow = createWorkflow( + "process-customers", + function (input) { + const customers = transform({ ids: input.customer_ids }, (input) => input.ids) + + // First invocation - no need to rename + const customer1 = fetchCustomerStep(customers[0]) + + // Second invocation - different name + const customer2 = fetchCustomerStep(customers[1]).config({ + name: "fetch-customer-2" + }) + + const result = transform({ customer1, customer2 }, (data) => ({ + customers: [data.customer1, data.customer2] + })) + + return new WorkflowResponse(result) + } +) + +// ❌ WRONG - Calling the same step multiple times without renaming +export const processCustomersWorkflow = createWorkflow( + "process-customers", + function (input) { + const customers = transform({ ids: input.customer_ids }, (input) => input.ids) + + // This will cause runtime errors - duplicate step names + const customer1 = fetchCustomerStep(customers[0]) + const customer2 = fetchCustomerStep(customers[1]) // ❌ Conflict! + + return new WorkflowResponse({ customers: [customer1, customer2] }) + } +) +``` + +**Why this matters:** +- Medusa uses step names to track execution state +- Duplicate names cause conflicts in the workflow execution engine +- Each step invocation needs a unique identifier +- The workflow will fail at runtime if steps aren't renamed + +### Variable Operations +- ❌ No direct variable manipulation or concatenation → Use `transform({ in }, ({ in }) => \`Transformed: ${in}\`)` instead +- Variables lack values until execution time - all operations must use `transform()` + +### Date/Time Operations +- ❌ No `new Date()` (will be fixed to load time) → Wrap in `transform()` for execution-time evaluation + +### Conditional Logic +- ❌ No `if`/`else` statements → Use `when(input, (input) => input.is_active).then(() => { /* steps */ })` instead +- ❌ No ternary operators (`? :`) → Use `transform()` instead +- ❌ No nullish coalescing (`??`) → Use `transform()` instead +- ❌ No logical OR (`||`) → Use `transform()` instead +- ❌ No optional chaining (`?.`) → Use `transform()` instead +- ❌ No double negation (`!!`) → Use `transform()` instead + +### Object Operations +- ❌ No object spreading (`...`) for destructuring or spreading properties → Use `transform()` to create new objects with desired properties + +```typescript +// ❌ WRONG - Object spreading in workflow +const myWorkflow = createWorkflow( + "process-data", + function (input: WorkflowInput) { + const updatedData = { + ...input.data, + newField: "value" + } // Won't work - spread operator not allowed + + step1(updatedData) +}) + +// ✅ CORRECT - Use transform to create new objects +import { transform } from "@medusajs/framework/workflows-sdk" + +const myWorkflow = createWorkflow( + "process-data", + function (input: WorkflowInput) { + const updatedData = transform( + { input }, + (data) => ({ + ...data.input.data, + newField: "value" + }) + ) + + step1(updatedData) +}) +``` + +### Loops +- ❌ No `for`/`while` loops → Use alternatives below based on your use case + +Workflow composition functions run at application load time to define the workflow structure, not to execute logic. Loops cannot be used directly in the composition function. Instead, use these patterns: + +**Alternative 1: Loop in Calling Code (Repeat entire workflow)** + +When you need to execute a workflow multiple times (e.g., once per item in an array), wrap the workflow execution in a loop in the code that calls the workflow: + +```typescript +// ❌ WRONG - Loop inside workflow composition +const myWorkflow = createWorkflow( + "hello-world", + function (input: WorkflowInput) { + for (const item of input.items) { + step1(item) // Won't work - loop runs at load time, not execution time + } +}) + +// ✅ CORRECT - Loop in calling code +// API route that calls the workflow +import { + MedusaRequest, + MedusaResponse, +} from "@medusajs/framework/http" +import myWorkflow from "../../workflows/my-workflow" + +export async function POST( + req: MedusaRequest, + res: MedusaResponse +) { + const { items } = req.body + + // Execute the workflow once for each item + for (const item of items) { + await myWorkflow(req.scope) + .run({ item }) + } + + res.status(200).send({ success: true }) +} + +// Workflow definition - processes a single item +const myWorkflow = createWorkflow( + "hello-world", + function (input: WorkflowInput) { + step1(input.item) +}) +``` + +**Alternative 2: Use `transform` for Array Operations (Prepare step inputs)** + +When you need to iterate over an array to prepare inputs for a step, use `transform()` to map over the array: + +```typescript +// ❌ WRONG - Loop to build array +const myWorkflow = createWorkflow( + "hello-world", + function (input: WorkflowInput) { + const stepInputs = [] + for (const item of input.items) { + stepInputs.push({ id: item.id }) // Won't work - loop runs at load time + } + step1(stepInputs) +}) + +// ✅ CORRECT - Use transform to map array +import { transform } from "@medusajs/framework/workflows-sdk" + +const myWorkflow = createWorkflow( + "hello-world", + function (input: WorkflowInput) { + const stepInputs = transform( + { + input, + }, + (data) => { + // This function runs at execution time + return data.input.items.map((item) => ({ id: item.id })) + } + ) + + step1(stepInputs) +}) +``` + +**Why this matters:** +- The workflow composition function runs once at application load time to define the structure +- Loops would execute at load time with no data, not at execution time with actual input +- Alternative 1 repeats the entire workflow (including rollback capability) for each item +- Alternative 2 processes arrays within a single workflow execution using `transform()` + +### Error Handling +- ❌ No `try-catch` blocks → See error handling patterns in Medusa documentation + +### Return Values +- ✅ Only return serializable values (primitives, plain objects) +- ❌ No non-serializable types (Maps, Sets, etc.) +- For buffers: Return as object property, then recreate with `Buffer.from()` when processing results + +## Step Best Practices + +1. **One mutation per step**: Ensures rollback mechanisms work correctly +2. **Idempotency**: Design steps to be safely retryable +3. **Explicit compensation input**: Specify what data the compensation function needs if different from step output +4. **Return StepResponse**: Always wrap your return value in `StepResponse` + +## Reusing Built-in Medusa Steps + +**⚠️ IMPORTANT**: Before creating custom steps, check if Medusa provides a built-in step for your use case. Reusing built-in steps is preferred over creating custom ones. + +### Common Built-in Steps to Reuse + +**Creating Links Between Modules:** + +**⚠️ CRITICAL - Link Order (Direction):** When creating links, the order of modules in `createRemoteLinkStep` MUST match the order in `defineLink()`. Mismatched order causes runtime errors. + +```typescript +// Link definition in src/links/review-product.ts +import { defineLink } from "@medusajs/framework/utils" +import ReviewModule from "../modules/review" +import ProductModule from "@medusajs/medusa/product" + +// Order: review FIRST, then product +export default defineLink( + { + linkable: ReviewModule.linkable.review, + isList: true, + }, + ProductModule.linkable.product +) +``` + +```typescript +// ✅ CORRECT - Order matches defineLink (review first, then product) +import { createRemoteLinkStep } from "@medusajs/medusa/core-flows" +import { createWorkflow, transform, WorkflowResponse } from "@medusajs/framework/workflows-sdk" +import { Modules } from "@medusajs/framework/utils" +import { REVIEW_MODULE } from "../modules/review" + +export const createReviewWorkflow = createWorkflow( + "create-review", + function (input) { + const review = createReviewStep(input) + + // Order MUST match defineLink: review first, then product + const linkData = transform({ review, input }, ({ review, input }) => [{ + [REVIEW_MODULE]: { + review_id: review.id, + }, + [Modules.PRODUCT]: { + product_id: input.product_id, + }, + }]) + + createRemoteLinkStep(linkData) + + return new WorkflowResponse({ review }) + } +) + +// ❌ WRONG - Order doesn't match defineLink (product first, then review) +const linkData = transform({ review, input }, ({ review, input }) => [{ + [Modules.PRODUCT]: { + product_id: input.product_id, + }, + [REVIEW_MODULE]: { + review_id: review.id, + }, +}]) // Runtime error: link direction mismatch! +``` + +```typescript +// ❌ WRONG - Don't create custom link steps +const createReviewLinkStep = createStep( + "create-review-link", + async ({ reviewId, productId }, { container }) => { + const link = container.resolve("link") + await link.create({ + product: { product_id: productId }, + review: { review_id: reviewId }, + }) + // This duplicates functionality that createRemoteLinkStep provides + } +) +``` + +**Removing Links:** + +```typescript +// ✅ CORRECT - Use Medusa's built-in dismissRemoteLinkStep +import { dismissRemoteLinkStep } from "@medusajs/medusa/core-flows" + +export const deleteReviewWorkflow = createWorkflow( + "delete-review", + function (input) { + const linkData = transform({ input }, ({ input }) => [{ + [Modules.PRODUCT]: { product_id: input.product_id }, + review: { review_id: input.review_id }, + }]) + + dismissRemoteLinkStep(linkData) + deleteReviewStep(input) + + return new WorkflowResponse({ success: true }) + } +) +``` + +**Querying Data in Workflows:** + +```typescript +// ✅ CORRECT - Use Medusa's built-in useQueryGraphStep +import { useQueryGraphStep } from "@medusajs/medusa/core-flows" +import { createWorkflow, WorkflowResponse } from "@medusajs/framework/workflows-sdk" + +export const getProductReviewsWorkflow = createWorkflow( + "get-product-reviews", + function (input) { + // Query product with reviews using built-in step + const { data: products } = useQueryGraphStep({ + entity: "product", + fields: ["id", "title", "reviews.*"], + filters: { + id: input.product_id, + }, + }) + + return new WorkflowResponse({ product: products[0] }) + } +) + +// ❌ WRONG - Don't create custom query steps +const queryProductStep = createStep( + "query-product", + async ({ productId }, { container }) => { + const query = container.resolve("query") + const { data } = await query.graph({ + entity: "product", + fields: ["id", "title", "reviews.*"], + filters: { id: productId }, + }) + return new StepResponse(data[0]) + } +) +// This duplicates functionality that useQueryGraphStep provides +``` + +**Why reuse built-in steps:** +- Already tested and optimized by Medusa +- Handles edge cases and error scenarios +- Maintains consistency with Medusa's internal workflows +- Includes proper compensation/rollback logic +- Less code to maintain + +**Other common built-in steps to look for:** +- Event emission steps +- Notification steps +- Inventory management steps +- Payment processing steps + +Check Medusa documentation or `@medusajs/medusa/core-flows` for available built-in steps before creating custom ones. + +## Business Logic and Validation Placement + +**CRITICAL**: All business logic and validation must be performed inside workflow steps, NOT in API routes. + +### ✅ CORRECT - Validation in Workflow Step + +```typescript +// src/workflows/steps/delete-review.ts +export const deleteReviewStep = createStep( + "delete-review", + async ({ reviewId, customerId }: Input, { container }) => { + const reviewModule = container.resolve("review") + + // Validation happens inside the step + const review = await reviewModule.retrieveReview(reviewId) + + if (review.customer_id !== customerId) { + throw new MedusaError( + MedusaError.Types.NOT_ALLOWED, + "You can only delete your own reviews" + ) + } + + await reviewModule.deleteReviews(reviewId) + + return new StepResponse({ id: reviewId }, reviewId) + }, + async (reviewId, { container }) => { + // Compensation: restore the review if needed + } +) +``` + +### ❌ WRONG - Validation in API Route + +```typescript +// src/api/store/reviews/[id]/route.ts +export async function DELETE(req: MedusaRequest, res: MedusaResponse) { + const { id } = req.params + const customerId = req.auth_context.actor_id + + // ❌ WRONG: Don't validate business rules in the route + const reviewModule = req.scope.resolve("review") + const review = await reviewModule.retrieveReview(id) + + if (review.customer_id !== customerId) { + throw new MedusaError(MedusaError.Types.NOT_ALLOWED, "Not your review") + } + + // ❌ WRONG: Don't call workflows after manual validation + const { result } = await deleteReviewWorkflow(req.scope).run({ + input: { reviewId: id } + }) +} +``` + +**Why this matters:** +- Workflows are the single source of truth for business logic +- Validation in routes bypasses workflow rollback mechanisms +- Makes testing harder and logic harder to reuse +- Breaks the Module → Workflow → API Route architecture + +## Advanced Features + +Workflows have advanced options to define retries, async behavior, pausing for human confirmation, and much more. Ask MedusaDocs for more details if these are relevant to your use case. diff --git a/.agents/skills/db-generate/SKILL.md b/.agents/skills/db-generate/SKILL.md new file mode 100644 index 0000000..abbd74d --- /dev/null +++ b/.agents/skills/db-generate/SKILL.md @@ -0,0 +1,23 @@ +--- +name: db-generate +description: Generate database migrations for a Medusa module +argument-hint: +allowed-tools: Bash(npx medusa db:generate:*) +--- + +# Generate Database Migrations + +Generate database migrations for the specified Medusa module. + +The user will provide the module name as an argument (e.g., `brand`, `product`, `custom-module`). + +For example: `/medusa-dev:db-generate brand` + +Use the Bash tool to execute the command `npx medusa db:generate `, replacing `` with the provided argument. + +Report the results to the user, including: + +- The module name for which migrations were generated +- Migration file name or location +- Any errors or warnings +- Next steps (running `npx medusa db:migrate` to apply the migrations) diff --git a/.agents/skills/db-migrate/SKILL.md b/.agents/skills/db-migrate/SKILL.md new file mode 100644 index 0000000..8c489f7 --- /dev/null +++ b/.agents/skills/db-migrate/SKILL.md @@ -0,0 +1,17 @@ +--- +name: db-migrate +description: Run database migrations in Medusa +allowed-tools: Bash(npx medusa db:migrate:*) +--- + +# Run Database Migrations + +Execute the Medusa database migration command to apply pending migrations. + +Use the Bash tool to execute: `npx medusa db:migrate` + +Report the migration results to the user, including: + +- Number of migrations applied +- Any errors that occurred +- Success confirmation diff --git a/.agents/skills/new-user/SKILL.md b/.agents/skills/new-user/SKILL.md new file mode 100644 index 0000000..914ecf1 --- /dev/null +++ b/.agents/skills/new-user/SKILL.md @@ -0,0 +1,25 @@ +--- +name: new-user +description: Create an admin user in Medusa +argument-hint: +allowed-tools: Bash(npx medusa user:*) +--- + +# Create Admin User + +Create a new admin user in Medusa with the specified email and password. + +The user will provide two arguments: +- First argument: email address +- Second argument: password + +For example: `/medusa-dev:user admin@test.com supersecret` + +Use the Bash tool to execute the command `npx medusa user -e -p `, replacing `` with the first argument and `` with the second argument. + +Report the results to the user, including: + +- Confirmation that the admin user was created successfully +- The email address of the created user +- Any errors that occurred +- Next steps (e.g., logging in to the admin dashboard) diff --git a/.agents/skills/storefront-best-practices/SKILL.md b/.agents/skills/storefront-best-practices/SKILL.md new file mode 100644 index 0000000..9a4cbf0 --- /dev/null +++ b/.agents/skills/storefront-best-practices/SKILL.md @@ -0,0 +1,421 @@ +--- +name: storefront-best-practices +description: ALWAYS use this skill when working on ecommerce storefronts, online stores, shopping sites. Use for ANY storefront component including checkout pages, cart, payment flows, product pages, product listings, navigation, homepage, or ANY page/component in a storefront. CRITICAL for adding checkout, implementing cart, integrating Medusa backend, or building any ecommerce functionality. Framework-agnostic (Next.js, SvelteKit, TanStack Start, React, Vue). Provides patterns, decision frameworks, backend integration guidance. +--- + +# Ecommerce Storefront Best Practices + +Comprehensive guidance for building modern, high-converting ecommerce storefronts covering UI/UX patterns, component design, layout structures, SEO optimization, and mobile responsiveness. + +## When to Apply + +**ALWAYS load this skill when working on ANY storefront task:** + +- **Adding checkout page/flow** - Payment, shipping, order placement +- **Implementing cart** - Cart page, cart popup, add to cart functionality +- **Building product pages** - Product details, product listings, product grids +- **Creating navigation** - Navbar, megamenu, footer, mobile menu +- **Integrating Medusa backend** - SDK setup, cart, products, payment +- **Any storefront component** - Homepage, search, filters, account pages +- Building new ecommerce storefronts from scratch +- Improving existing shopping experiences and conversion rates +- Optimizing for usability, accessibility, and SEO +- Designing mobile-responsive ecommerce experiences + +**Example prompts that should trigger this skill:** +- "Add a checkout page" +- "Implement shopping cart" +- "Create product listing page" +- "Connect to Medusa backend" +- "Add navigation menu" +- "Build homepage for store" + +## CRITICAL: Load Reference Files When Needed + +**⚠️ ALWAYS load `reference/design.md` BEFORE creating ANY UI component** +- Discovers existing design tokens (colors, fonts, spacing, patterns) +- Prevents introducing inconsistent styles +- Provides guardrails for maintaining brand consistency +- **Required for every component, not just new storefronts** + +**Load these references based on what you're implementing:** + +- **Starting a new storefront?** → MUST load `reference/design.md` first to discover user preferences +- **Connecting to backend API?** → MUST load `reference/connecting-to-backend.md` first +- **Connecting to Medusa backend?** → MUST load `reference/medusa.md` for SDK setup, pricing, regions, and Medusa patterns +- **Implementing homepage?** → MUST load `reference/components/navbar.md`, `reference/components/hero.md`, `reference/components/footer.md`, and `reference/layouts/home-page.md` +- **Implementing navigation?** → MUST load `reference/components/navbar.md` and optionally `reference/components/megamenu.md` +- **Building product listing?** → MUST load `reference/layouts/product-listing.md` first +- **Building product details?** → MUST load `reference/layouts/product-details.md` first +- **Implementing checkout?** → MUST load `reference/layouts/checkout.md` first +- **Optimizing for SEO?** → MUST load `reference/seo.md` first +- **Optimizing for mobile?** → MUST load `reference/mobile-responsiveness.md` first + +**Minimum requirement:** Load at least 1-2 reference files relevant to your specific task before implementing. + +## Planning and Implementation Workflow + +**IMPORTANT: If you create a plan for implementing storefront features, include the following in your plan:** + +When implementing each component, page, layout, or feature in the plan: +1. **Refer back to this skill** before starting implementation +2. **Load relevant reference files** listed above for the specific component/page you're building +3. **Follow the patterns and guidance** in the reference files +4. **Check common mistakes** sections to avoid known pitfalls + +**Example plan structure:** + +``` +Task 1: Implement Navigation +- Load reference/components/navbar.md +- Follow patterns from navbar.md (dynamic category fetching, cart visibility, etc.) +- Refer to skill for common mistakes (e.g., hardcoding categories) + +Task 2: Implement Product Listing Page +- Load reference/layouts/product-listing.md +- Follow pagination/filtering patterns from product-listing.md +- Use reference/components/product-card.md for product grid items +- Check skill for backend integration guidance + +Task 3: Implement Checkout Flow +- Load reference/layouts/checkout.md +- Load reference/medusa.md for Medusa payment integration +- Follow component architecture recommendations (separate step components) +- Refer to skill for payment method fetching requirements +``` + +**Why this matters:** +- Plans provide high-level strategy +- Reference files provide detailed implementation patterns +- Skill file contains critical mistakes to avoid +- Following this workflow ensures consistency and best practices + +## Critical Ecommerce-Specific Patterns + +### Accessibility +- **CRITICAL: Cart count updates require `aria-live="polite"`** - Screen readers won't announce without it +- Ensure keyboard navigation for all cart/checkout interactions + +### Mobile +- **Sticky bottom elements MUST use `env(safe-area-inset-bottom)`** - iOS home indicator will cut off purchase buttons otherwise +- 44px minimum touch targets for cart actions, variant selectors, quantity buttons + +### Performance +- **ALWAYS add `loading="lazy"` to product images below fold** - Don't rely on browser defaults +- Optimize product images for mobile (<500KB) - Most ecommerce traffic is mobile + +### Conversion Optimization +- Clear CTAs throughout shopping flow +- Minimal friction in checkout (guest checkout if supported) +- Trust signals (reviews, security badges, return policy) near purchase buttons +- Clear pricing and shipping information upfront + +### SEO +- **Product schema (JSON-LD) required** - Critical for Google Shopping and rich snippets +- Use [PageSpeed Insights](https://pagespeed.web.dev/) to measure Core Web Vitals + +### Visual Design +- **NEVER use emojis** in storefront UI - Use icons or images instead (unprofessional, accessibility issues) + +### Backend Integration +- **Backend detection**: If in monorepo, check for backend directory. If unsure, ask user which backend is used. +- **NEVER hardcode dynamic content**: Always fetch categories, regions, products, shipping options, etc. from backend - they change frequently +- Never assume API structure - verify endpoints and data formats + +### ⚠️ CRITICAL: Backend SDK Method Verification Workflow + +**YOU MUST FOLLOW THIS EXACT WORKFLOW BEFORE WRITING CODE THAT CONNECTS TO BACKEND:** + +**Step 1: PAUSE - Do NOT write code yet** +- You are about to write code that calls a backend API or SDK method (e.g., Medusa SDK, REST API, GraphQL) +- **STOP** - Do not proceed to code without verification + +**Step 2: QUERY the documentation or MCP server** +- **If MCP server available**: Query it for the exact method (for example, medusa MCP) +- **If no MCP server**: Search official documentation +- **Find**: Exact method name, parameters, return type + +**Step 3: VERIFY what you found** +- State out loud to the user: "I need to verify the correct method for [operation]. Let me check [MCP server/documentation]." +- Show the user what you found: "According to [source], the method is `sdk.store.cart.methodName(params)`" +- Confirm the method signature and parameters + +**Step 4: ONLY THEN write the code** +- Now you can write code using the verified method +- Use the exact signature you found + +**Step 5: CHECK for TypeScript errors** +- After writing the code, check for any TypeScript/type errors related to the SDK +- If you see type errors on SDK methods, it means you used an incorrect method name or wrong parameters +- **Type errors are a sign you didn't verify correctly** - Go back to Step 2 + +**THIS IS NOT OPTIONAL - THIS IS MANDATORY ERROR PREVENTION** + +**It is a CRITICAL ERROR to:** +- ❌ Write code that calls backend APIs/SDKs without explicitly querying docs/MCP first +- ❌ Guess method names or parameters +- ❌ Ignore TypeScript errors on SDK methods (errors indicate incorrect method usage) +- ❌ Copy examples from this skill without verification (examples may be outdated) +- ❌ Assume SDK methods match REST API endpoints + +**For Medusa specifically:** +- **Medusa pricing**: Display prices as-is - DO NOT divide by 100 (unlike Stripe, Medusa stores prices in display format) +- **Medusa MCP server**: https://docs.medusajs.com/mcp - Recommend setup if not installed +- Load `reference/medusa.md` for Medusa-specific patterns (regions, pricing, etc.) + +### Routing Patterns +- **ALWAYS use dynamic routes** for products and categories - NEVER create static pages for individual items +- Product pages: Use dynamic routes like `/products/[handle]` or `/products/$handle`, NOT `/products/shirt.tsx` +- Category pages: Use dynamic routes like `/categories/[handle]` or `/categories/$handle`, NOT `/categories/women.tsx` +- Framework-specific patterns: + - **Next.js App Router**: `app/products/[handle]/page.tsx` or `app/products/[id]/page.tsx` + - **Next.js Pages Router**: `pages/products/[handle].tsx` + - **SvelteKit**: `routes/products/[handle]/+page.svelte` + - **TanStack Start**: `routes/products/$handle.tsx` + - **Remix**: `routes/products.$handle.tsx` +- Why: Dynamic routes scale to any number of products/categories without creating individual files +- Static routes are unmaintainable and don't scale (imagine creating 1000 product files) + +## Pattern Selection Guides + +When you need to choose between implementation patterns, load the relevant reference file: + +- **Checkout strategy** (single-page vs multi-step) → Load `reference/layouts/checkout.md` +- **Navigation strategy** (dropdown vs megamenu) → Load `reference/components/navbar.md` and `reference/components/megamenu.md` +- **Product listing strategy** (pagination vs infinite scroll vs load more) → Load `reference/layouts/product-listing.md` +- **Search strategy** (autocomplete vs filters vs natural language) → Load `reference/components/search.md` +- **Mobile vs desktop priorities** → Load `reference/mobile-responsiveness.md` +- **Variant selection** (text vs swatches vs configurator) → Load `reference/layouts/product-details.md` +- **Cart pattern** (popup vs drawer vs page navigation) → Load `reference/components/cart-popup.md` and `reference/layouts/cart.md` +- **Trust signals strategy** → Load `reference/layouts/product-details.md` and `reference/layouts/checkout.md` + +Each reference file contains decision frameworks with specific criteria to help you choose the right pattern for your context. + +## Quick Reference + +### General + +``` +reference/connecting-to-backend.md - Framework detection, API setup, backend integration patterns +reference/medusa.md - Medusa SDK integration, pricing, regions, TypeScript types +reference/design.md - User preferences, brand identity, design systems +reference/seo.md - Meta tags, structured data, Core Web Vitals +reference/mobile-responsiveness.md - Mobile-first design, responsive breakpoints, touch interactions +``` + +### Components + +``` +reference/components/navbar.md - Desktop/mobile navigation, logo, menu, cart icon, load for ALL pages +reference/components/megamenu.md - Category organization, featured products, mobile alternatives +reference/components/cart-popup.md - Add-to-cart feedback, mini cart display +reference/components/country-selector.md - Country/region selection, currency, pricing, Medusa regions +reference/components/breadcrumbs.md - Category hierarchy, structured data markup +reference/components/search.md - Search input, autocomplete, results, filters +reference/components/product-reviews.md - Review display, rating aggregation, submission +reference/components/hero.md - Hero layouts, CTA placement, image optimization +reference/components/popups.md - Newsletter signup, discount popups, exit-intent +reference/components/footer.md - Content organization, navigation, social media, load for ALL pages +reference/components/product-card.md - Product images, pricing, add to cart, badges +reference/components/product-slider.md - Carousel implementation, mobile swipe, accessibility +``` + +### Layouts + +``` +reference/layouts/home-page.md - Hero, featured categories, product listings +reference/layouts/product-listing.md - Grid/list views, filters, sorting, pagination +reference/layouts/product-details.md - Image gallery, variant selection, related products +reference/layouts/cart.md - Cart items, quantity updates, promo codes +reference/layouts/checkout.md - Multi-step/single-page, address forms, payment +reference/layouts/order-confirmation.md - Order number, summary, delivery info +reference/layouts/account.md - Dashboard, order history, address book +reference/layouts/static-pages.md - FAQ, about, contact, shipping/returns policies +``` + +### Features + +``` +reference/features/wishlist.md - Add to wishlist, wishlist page, move to cart +reference/features/promotions.md - Promotional banners, discount codes, sale badges +``` + +## Common Implementation Patterns + +### Starting a New Storefront + +**IMPORTANT: For each step below, load the referenced files BEFORE implementing that step.** + +``` +1. Discovery Phase → Read design.md for user preferences +2. Foundation Setup → Read connecting-to-backend.md (or medusa.md for Medusa), mobile-responsiveness.md, seo.md +3. Core Components → Implement navbar.md, footer.md +4. Home Page → Read home-page.md +5. Product Browsing → Read product-listing.md, product-card.md, search.md +6. Product Details → Read product-details.md, product-reviews.md +7. Cart & Checkout → Read cart-popup.md, cart.md, checkout.md, order-confirmation.md +8. User Account → Read account.md +9. Additional Features → Read wishlist.md, promotions.md +10. Optimization → SEO audit (seo.md), mobile testing (mobile-responsiveness.md) +``` + +Even if you create an implementation plan, refer back to the skill and load relevant reference files when implementing each step. + +### Shopping Flow Pattern + +``` +Browse → View → Cart → Checkout + +Browse: home-page.md → product-listing.md +View: product-details.md + product-reviews.md +Cart: cart-popup.md → cart.md +Checkout: checkout.md → order-confirmation.md +``` + +### Component Selection Guide + +**For product grids and filtering** → `product-listing.md` and `product-card.md` +**For product cards** → `product-card.md` +**For navigation** → `navbar.md` and `megamenu.md` +**For search functionality** → `search.md` +**For checkout flow** → `checkout.md` +**For promotions and sales** → `promotions.md` + +## Design Considerations + +Before implementing, consider: + +1. **User preferences** - Read `design.md` to discover design style preferences +2. **Brand identity** - Colors, typography, tone that match the brand +3. **Target audience** - B2C vs B2B, demographics, device usage +4. **Product type** - Fashion vs electronics vs groceries affect layout choices +5. **Business requirements** - Multi-currency, multi-language, region-specific +6. **Backend system** - API structure affects component implementation + +## Integration with Medusa + +[Medusa](https://medusajs.com) is a modern, flexible ecommerce backend. Consider Medusa when: + +- Building a new ecommerce storefront +- Need a headless commerce solution +- Want built-in support for multi-region, multi-currency +- Need powerful promotion and discount engine +- Require flexible product modeling + +For detailed Medusa integration guidance, see `reference/medusa.md`. For general backend patterns, see `reference/connecting-to-backend.md`. + +### Framework Agnostic + +All guidance is framework-agnostic. Examples use React/TypeScript where code demonstrations are helpful, but patterns apply to: + +- Next.js +- SvelteKit +- Tanstack Start +- Any modern frontend framework + +## Minimum Viable Features + +**Mandatory for launch (core shopping flow):** +- Navbar with cart, categories, search +- Product listing with filtering and pagination +- Product details with variant selection +- Add to cart functionality +- Cart page with item management +- Checkout flow (shipping, payment, review) +- Order confirmation page + +**Nice-to-have (add if time permits):** +- Related products recommendations +- Product reviews and ratings +- Wishlist functionality +- Image zoom on product pages +- Bottom navigation on mobile +- Mega-menu for navigation +- Newsletter signup +- Product comparison +- Quick view modals + +**User-dependent (ask before implementing):** +- Guest checkout vs login-required +- Account dashboard features +- Multi-language support +- Multi-currency support +- Live chat support + +## Top Ecommerce Mistakes to Avoid + +Before implementing, watch out for these common ecommerce-specific pitfalls: + +**1. Cart and Navigation Mistakes** +- ❌ Hiding cart indicator in mobile hamburger menu (keep always visible) +- ❌ Not showing real-time cart count updates +- ❌ **CRITICAL: Missing `aria-live="polite"` on cart count** - Screen readers won't announce cart updates without it +- ❌ Not displaying variant details (size, color, etc.) in cart popup - only showing product title +- ❌ Megamenu closes when hovering over dropdown content (must stay open when hovering trigger OR dropdown) +- ❌ **CRITICAL: Megamenu positioning errors** - Three common mistakes: + - ❌ Navbar doesn't have `position: relative` (megamenu won't position correctly) + - ❌ Megamenu positioned relative to trigger button instead of navbar (use `absolute left-0` on megamenu) + - ❌ Megamenu doesn't span full width (must use `right-0` or `w-full`, not just `w-auto`) +- ❌ Hardcoding categories, featured products, or any dynamic content instead of fetching from backend +- ❌ No clear indication of current page in category navigation + +**2. Product Browsing Mistakes** +- ❌ Creating static routes for products/categories (use dynamic routes like `/products/[handle]` instead of `/products/shirt.tsx`) +- ❌ Missing "no products found" empty state with helpful suggestions +- ❌ No loading indicators while fetching products +- ❌ Pagination without SEO-friendly URLs (for search engines) +- ❌ Filter selections that don't persist on page reload + +**3. Product Details Mistakes** +- ❌ Enabling "Add to Cart" before variant selection (size, color, etc.) +- ❌ Missing product images optimization (large uncompressed images) +- ❌ Navigating away from product page after adding to cart (stay on page) +- ❌ Using emojis in UI instead of icons or images (unprofessional, accessibility issues) + +**4. Design and Consistency Mistakes** +- ❌ **CRITICAL: Not loading `reference/design.md` before creating ANY UI component** - Leads to inconsistent colors, fonts, and styles +- ❌ Introducing new colors without checking existing theme first +- ❌ Adding new fonts without verifying what's already used +- ❌ Using arbitrary Tailwind values when theme tokens exist +- ❌ Not detecting Tailwind version (v3 vs v4) - Causes syntax errors + +**5. Checkout and Conversion Mistakes** +- ❌ Requiring account creation to checkout (offer guest checkout if backend supports it) +- ❌ Not fetching payment methods from backend - assuming available payment options or skipping payment method selection +- ❌ Overly complex multi-step checkout (4+ steps kills conversion) - Optimal is 3 steps: Shipping Info, Delivery Method + Payment, Review +- ❌ Missing trust signals (secure checkout badge, return policy link) +- ❌ Not handling out-of-stock errors gracefully during checkout + +**6. Mobile Experience Mistakes** +- ❌ Touch targets smaller than 44x44px (buttons, links, form fields) +- ❌ Desktop-style hover menus on mobile (use tap/click instead) +- ❌ Not optimizing images for mobile (loading huge desktop images) +- ❌ Missing mobile-specific patterns (bottom nav, drawer filters) + +**7. Performance and SEO Mistakes** +- ❌ Missing structured data (Product schema) for SEO +- ❌ No explicit image lazy loading (don't assume browser defaults) - Always add `loading="lazy"` to images below the fold +- ❌ Missing meta tags and Open Graph for social sharing +- ❌ Not optimizing Core Web Vitals (LCP, FID, CLS) - Use [PageSpeed Insights](https://pagespeed.web.dev/) or Lighthouse to measure + +**8. Backend Integration Mistakes** +- ❌ **ERROR: Writing code that calls backend APIs/SDKs without following the 5-step verification workflow** - You MUST: 1) PAUSE, 2) QUERY docs/MCP, 3) VERIFY with user, 4) Write code, 5) CHECK for type errors +- ❌ **ERROR: Ignoring TypeScript errors on SDK methods** - Type errors mean you used wrong method names or parameters. Go back and verify with docs/MCP +- ❌ **ERROR: Guessing API method names, SDK methods, or parameters** - Always verify exact method signatures before use +- ❌ **ERROR: Not using Medusa MCP server when available** - If using Medusa backend, always query MCP server for methods +- ❌ **ERROR: Copying code examples without verifying they're current** - Examples may be outdated, always verify first +- ❌ Not detecting which backend is being used (check monorepo, ask user if unsure) +- ❌ Assuming API structure without checking backend documentation or MCP server +- ❌ Hardcoding dynamic content (categories, regions, products, etc.) instead of fetching from backend +- ❌ Defining custom types for Medusa entities instead of using `@medusajs/types` package +- ❌ Initializing Medusa SDK without publishable API key (required for multi-region stores and product pricing) +- ❌ Fetching Medusa products without passing `region_id` query parameter (causes missing or incorrect pricing) +- ❌ Showing all countries in Medusa checkout - should only show countries from cart's region +- ❌ Dividing Medusa prices by 100 (Medusa stores prices as-is, not in cents like Stripe) +- ❌ Missing Vite SSR config for Medusa SDK (add `ssr.noExternal: ['@medusajs/js-sdk']` to vite.config.ts) +- ❌ Running Medusa storefront on port other than 8000 (causes CORS errors - Medusa backend expects port 8000 by default) +- ❌ Not handling loading, error, and empty states for API calls +- ❌ Making API calls on client-side that should be server-side (SEO, security) +- ❌ Not implementing proper error messages ("Error occurred" vs "Product out of stock") +- ❌ Missing cache invalidation (stale product data, prices, inventory) +- ❌ **Not clearing cart state after order is placed** - Cart popup shows old items because cart wasn't reset from Context/localStorage/cache diff --git a/.agents/skills/storefront-best-practices/reference/components/breadcrumbs.md b/.agents/skills/storefront-best-practices/reference/components/breadcrumbs.md new file mode 100644 index 0000000..1b3d61c --- /dev/null +++ b/.agents/skills/storefront-best-practices/reference/components/breadcrumbs.md @@ -0,0 +1,123 @@ +# Breadcrumbs Component + +## Contents + +- [Overview](#overview) +- [When to Use Breadcrumbs](#when-to-use-breadcrumbs) +- [Ecommerce Breadcrumb Patterns](#ecommerce-breadcrumb-patterns) +- [Mobile Breadcrumbs](#mobile-breadcrumbs) +- [SEO Structured Data](#seo-structured-data) +- [Checklist](#checklist) + +## Overview + +Breadcrumbs show the user's location within the site hierarchy (Home → Category → Subcategory → Product). Critical for ecommerce navigation and SEO. + +**Assumed knowledge**: AI agents know how to build breadcrumbs with separators and links. This guide focuses on ecommerce-specific patterns. + +### Key Requirements + +- Show full path from homepage to current page +- Each level clickable (except current page) +- Position below navbar, above page title +- Include structured data for SEO (JSON-LD) +- Mobile-optimized (back link pattern) + +## When to Use Breadcrumbs + +**Use for:** +- Product pages (Home → Category → Subcategory → Product) +- Category pages (Home → Category → Subcategory) +- Deep site hierarchies (3+ levels) +- Large catalogs with many categories + +**Don't use for:** +- Homepage (no parent pages) +- Flat site structures (1-2 levels) +- Checkout flow (linear, not hierarchical) +- Search results (not hierarchical) + +## Ecommerce Breadcrumb Patterns + +### Product Page Breadcrumbs + +**Standard pattern:** +- Home / Category / Subcategory / Product Name +- Example: Home / Electronics / Laptops / Gaming Laptop Pro + +**Key considerations:** +- All levels except product name are clickable +- Product name is current page (non-clickable, darker text) +- Shows product's location in catalog + +**Multiple category membership:** +- If product in multiple categories, choose primary/canonical +- Match category in URL or navigation path +- Be consistent across site + +### Category Page Breadcrumbs + +**Standard pattern:** +- Home / Parent Category / Current Category +- Example: Home / Electronics / Laptops + +**Current category:** +- Non-clickable (plain text) +- Visually distinct from links (darker or bold) + +### Path Construction + +**Hierarchy:** +- Start with "Home" (or home icon) +- Follow category hierarchy +- End with current page +- Maximum 5-6 levels (keep shallow) + +**URL alignment:** +- Breadcrumb path should match URL hierarchy +- Consistent naming between URLs and breadcrumbs +- Example: `/categories/electronics/laptops` → "Home / Electronics / Laptops" + +## Mobile Breadcrumbs + +### Mobile Pattern: Collapse to Back Link + +**Recommended approach:** +- Show only previous level as back link +- Back arrow icon (←) + parent page name +- Example: "← Gaming Laptops" + +**Why:** +- Saves vertical space on mobile +- Clear affordance (back navigation) +- Simpler than full breadcrumb trail +- Mobile users have device back button + +**Alternative: Truncated path** +- Show "Home ... Current Page" +- Hide middle levels +- Balances space and context + +## SEO Structured Data + +**BreadcrumbList schema (CRITICAL)**: Add JSON-LD structured data. Breadcrumbs appear in search results, improves CTR, helps search engines understand site structure. + +**Implementation**: schema.org BreadcrumbList with items array. Each item has position (1, 2, 3...), name, and URL. See seo.md for schema details. + +## Checklist + +**Essential features:** + +- [ ] Positioned below navbar, above page title +- [ ] Full path shown (Home → Category → Product) +- [ ] All levels clickable except current page +- [ ] Current page visually distinct (non-clickable, darker) +- [ ] Clear separators (›, /, > or chevron) +- [ ] Mobile: Back link pattern ("← Category") +- [ ] Structured data (JSON-LD BreadcrumbList) +- [ ] Semantic HTML (`