npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@jsm406/medusa-products-bought-together

v1.0.2

Published

Monitor and display products that are frequently bought together by customers

Readme

@jsm406/medusa-products-bought-together

Plugin de Medusa v2 que rastrea y muestra productos que son comprados juntos frecuentemente por los clientes.

Caracteristicas

  • Seguimiento automatico: Cuando un cliente realiza un pedido, el plugin registra automaticamente que productos fueron comprados juntos
  • API Store: Endpoint para obtener productos comprados juntos con datos completos del producto (titulo, precio, imagen, thumbnail)
  • API Admin: Endpoint para administrar y ver todas las parejas de productos
  • Admin UI: Interfaz de administracion para visualizar las parejas de productos y sus frecuencias
  • Ordenacion por frecuencia: Los productos mas comprados juntos aparecen primero
  • Configurable: Limite de resultados configurable via query params

Instalacion

npm install @jsm406/medusa-products-bought-together

Configuracion

Agrega el plugin en tu medusa-config.ts:

module.exports = defineConfig({
  // ...
  plugins: [
    {
      resolve: "@jsm406/medusa-products-bought-together",
      options: {},
    },
  ],
})

Migraciones

Despues de instalar el plugin, ejecuta las migraciones para crear la tabla necesaria:

npx medusa db:migrate

Uso

API Store

Obtener productos comprados juntos para un producto especifico:

GET /store/products-bought-together/{product_id}?limit=10

Parametros:

  • product_id (path): ID del producto
  • limit (query, opcional): Numero maximo de resultados (default: 10)

Respuesta:

{
  "products_bought_together": [
    {
      "id": "pbt_123",
      "frequency": 15,
      "productId": "prod_456",
      "product": {
        "id": "prod_456",
        "title": "Producto Ejemplo",
        "handle": "producto-ejemplo",
        "thumbnail": "https://...",
        "status": "published",
        "variants": [...]
      }
    }
  ]
}

API Admin

Obtener todas las parejas de productos comprados juntos:

GET /admin/products-bought-together?limit=50&offset=0

Parametros:

  • limit (query, opcional): Numero maximo de resultados (default: 50)
  • offset (query, opcional): Offset para paginacion (default: 0)

Respuesta:

{
  "products_bought_together": [
    {
      "id": "pbt_123",
      "frequency": 15,
      "productId1": "prod_456",
      "productId2": "prod_789",
      "product1": {
        "id": "prod_456",
        "title": "Producto 1",
        "handle": "producto-1",
        "thumbnail": "https://...",
        "status": "published"
      },
      "product2": {
        "id": "prod_789",
        "title": "Producto 2",
        "handle": "producto-2",
        "thumbnail": "https://...",
        "status": "published"
      }
    }
  ],
  "count": 100,
  "limit": 50,
  "offset": 0
}

Admin UI

Accede a la interfaz de administracion en:

/admin/products-bought-together

La interfaz muestra:

  • Tabla con todas las parejas de productos
  • Frecuencia de compras conjuntas
  • Imagenes y titulos de productos
  • Paginacion y ordenacion por frecuencia

Uso en el Storefront

Ejemplo básico con fetch

// Función para obtener productos comprados juntos
async function getProductsBoughtTogether(productId: string, limit = 4) {
  const response = await fetch(
    `${MEDUSA_BACKEND_URL}/store/products-bought-together/${productId}?limit=${limit}`,
    {
      headers: {
        "x-publishable-api-key": MEDUSA_PUBLISHABLE_KEY,
      },
    }
  )
  
  if (!response.ok) {
    throw new Error("Error al obtener productos comprados juntos")
  }
  
  const data = await response.json()
  return data.products_bought_together
}

Componente React/Next.js (App Router)

// app/components/products-bought-together.tsx
import { getProductsBoughtTogether } from "@/lib/medusa"
import Image from "next/image"
import Link from "next/link"

type ProductBoughtTogether = {
  id: string
  frequency: number
  productId: string
  product: {
    id: string
    title: string
    handle: string
    thumbnail: string | null
    variants: Array<{
      id: string
      calculated_price: {
        calculated_amount: number
        currency_code: string
      }
    }>
  }
}

export default async function ProductsBoughtTogether({ 
  productId 
}: { 
  productId: string 
}) {
  const items = await getProductsBoughtTogether(productId, 4)
  
  if (!items || items.length === 0) {
    return null
  }
  
  return (
    <section className="py-8">
      <h2 className="text-2xl font-bold mb-6">
        Frecuentemente comprados juntos
      </h2>
      <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
        {items.map((item: ProductBoughtTogether) => (
          <Link 
            key={item.id} 
            href={`/products/${item.product.handle}`}
            className="group"
          >
            <div className="border rounded-lg p-4 hover:shadow-lg transition-shadow">
              {item.product.thumbnail && (
                <div className="relative aspect-square mb-3">
                  <Image
                    src={item.product.thumbnail}
                    alt={item.product.title}
                    fill
                    className="object-cover rounded"
                  />
                </div>
              )}
              <h3 className="font-medium text-sm line-clamp-2 group-hover:text-primary">
                {item.product.title}
              </h3>
              {item.product.variants[0]?.calculated_price && (
                <p className="text-sm text-gray-600 mt-1">
                  {new Intl.NumberFormat("es-ES", {
                    style: "currency",
                    currency: item.product.variants[0].calculated_price.currency_code,
                  }).format(item.product.variants[0].calculated_price.calculated_amount)}
                </p>
              )}
              <p className="text-xs text-gray-500 mt-2">
                {item.frequency} {item.frequency === 1 ? "compra conjunta" : "compras conjuntas"}
              </p>
            </div>
          </Link>
        ))}
      </div>
    </section>
  )
}

Integración en la página de producto

// app/products/[handle]/page.tsx
import { getProductByHandle } from "@/lib/medusa"
import ProductsBoughtTogether from "@/components/products-bought-together"

export default async function ProductPage({ 
  params 
}: { 
  params: { handle: string } 
}) {
  const product = await getProductByHandle(params.handle)
  
  return (
    <div className="container mx-auto px-4 py-8">
      {/* Contenido del producto */}
      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
        {/* Galería de imágenes, detalles, etc. */}
      </div>
      
      {/* Sección de productos comprados juntos */}
      <ProductsBoughtTogether productId={product.id} />
      
      {/* Productos relacionados (existente) */}
      {/* ... */}
    </div>
  )
}

Ejemplo con Medusa SDK

// lib/medusa.ts
import Medusa from "@medusajs/medusa-js"

const medusa = new Medusa({ 
  baseUrl: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL!,
  maxRetries: 3,
  publishableApiKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,
})

export async function getProductsBoughtTogether(
  productId: string, 
  limit = 4
) {
  const response = await fetch(
    `${process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL}/store/products-bought-together/${productId}?limit=${limit}`,
    {
      headers: {
        "x-publishable-api-key": process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY!,
      },
      next: { revalidate: 3600 }, // Cache por 1 hora
    }
  )
  
  if (!response.ok) {
    console.error("Error fetching products bought together")
    return []
  }
  
  const data = await response.json()
  return data.products_bought_together
}

Componente con estado (Client Component)

// components/products-bought-together-client.tsx
"use client"

import { useEffect, useState } from "react"
import Image from "next/image"
import Link from "next/link"

type ProductBoughtTogether = {
  id: string
  frequency: number
  productId: string
  product: {
    id: string
    title: string
    handle: string
    thumbnail: string | null
    variants: Array<{
      calculated_price: {
        calculated_amount: number
        currency_code: string
      }
    }>
  }
}

export default function ProductsBoughtTogetherClient({ 
  productId 
}: { 
  productId: string 
}) {
  const [items, setItems] = useState<ProductBoughtTogether[]>([])
  const [loading, setLoading] = useState(true)
  
  useEffect(() => {
    async function fetchProducts() {
      try {
        const response = await fetch(
          `/api/products-bought-together?productId=${productId}&limit=4`
        )
        const data = await response.json()
        setItems(data.products_bought_together)
      } catch (error) {
        console.error("Error:", error)
      } finally {
        setLoading(false)
      }
    }
    
    fetchProducts()
  }, [productId])
  
  if (loading) {
    return (
      <section className="py-8">
        <h2 className="text-2xl font-bold mb-6">
          Frecuentemente comprados juntos
        </h2>
        <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
          {[...Array(4)].map((_, i) => (
            <div key={i} className="border rounded-lg p-4 animate-pulse">
              <div className="aspect-square bg-gray-200 rounded mb-3" />
              <div className="h-4 bg-gray-200 rounded mb-2" />
              <div className="h-3 bg-gray-200 rounded w-1/2" />
            </div>
          ))}
        </div>
      </section>
    )
  }
  
  if (items.length === 0) return null
  
  return (
    <section className="py-8">
      <h2 className="text-2xl font-bold mb-6">
        Frecuentemente comprados juntos
      </h2>
      <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
        {items.map((item) => (
          <Link 
            key={item.id} 
            href={`/products/${item.product.handle}`}
            className="group"
          >
            <div className="border rounded-lg p-4 hover:shadow-lg transition-shadow">
              {item.product.thumbnail && (
                <div className="relative aspect-square mb-3">
                  <Image
                    src={item.product.thumbnail}
                    alt={item.product.title}
                    fill
                    className="object-cover rounded"
                  />
                </div>
              )}
              <h3 className="font-medium text-sm line-clamp-2 group-hover:text-primary">
                {item.product.title}
              </h3>
              {item.product.variants[0]?.calculated_price && (
                <p className="text-sm text-gray-600 mt-1">
                  {new Intl.NumberFormat("es-ES", {
                    style: "currency",
                    currency: item.product.variants[0].calculated_price.currency_code,
                  }).format(item.product.variants[0].calculated_price.calculated_amount)}
                </p>
              )}
              <p className="text-xs text-gray-500 mt-2">
                {item.frequency} {item.frequency === 1 ? "compra conjunta" : "compras conjuntas"}
              </p>
            </div>
          </Link>
        ))}
      </div>
    </section>
  )
}

Variables de entorno necesarias

# .env.local
NEXT_PUBLIC_MEDUSA_BACKEND_URL=http://localhost:9000
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY=your_publishable_api_key

Como funciona

  1. Subscriber: Escucha el evento order.placed
  2. Extraccion: Extrae los IDs de productos unicos del pedido
  3. Generacion de pares: Crea todas las combinaciones posibles de pares de productos
  4. Almacenamiento: Guarda o actualiza la frecuencia de cada par en la base de datos
  5. Consulta: La API Store devuelve los productos completos usando query.graph de Medusa

Desarrollo local

Para desarrollar el plugin localmente:

# Instalar dependencias
npm install

# Publicar localmente con yalc
npx medusa plugin:publish

# En tu proyecto Medusa, instalar el plugin local
npx medusa plugin:add @jsm406/medusa-products-bought-together

# Watch para cambios en tiempo real
npx medusa plugin:develop

Build

npx medusa plugin:build

Publicar en NPM

npm version patch  # o minor, major
npx medusa plugin:build
npm publish

Estructura del plugin

src/
├── modules/
│   └── productsBoughtTogether/
│       ├── index.ts          # Definicion del modulo
│       ├── service.ts        # Logica del servicio
│       ├── models/           # Modelo de datos
│       └── migrations/       # Migraciones de BD
├── subscribers/
│   └── pbt-order-handler.ts  # Subscriber de order.placed
├── api/
│   ├── store/                # API publica para storefront
│   └── admin/                # API para administracion
├── admin/
│   └── routes/               # UI de administracion
└── workflows/
    └── index.ts              # Exports de workflows

Compatibilidad

  • Medusa v2.17.2+
  • Node.js >= 20
  • PostgreSQL

Licencia

MIT

Autor

jsm406

Credits

Inspirado en medusa-products-bought-together de RSC-Labs.

medusa-products-bought-together