@jsm406/medusa-products-bought-together
v1.0.2
Published
Monitor and display products that are frequently bought together by customers
Maintainers
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-togetherConfiguracion
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:migrateUso
API Store
Obtener productos comprados juntos para un producto especifico:
GET /store/products-bought-together/{product_id}?limit=10Parametros:
product_id(path): ID del productolimit(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=0Parametros:
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-togetherLa 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_keyComo funciona
- Subscriber: Escucha el evento
order.placed - Extraccion: Extrae los IDs de productos unicos del pedido
- Generacion de pares: Crea todas las combinaciones posibles de pares de productos
- Almacenamiento: Guarda o actualiza la frecuencia de cada par en la base de datos
- Consulta: La API Store devuelve los productos completos usando
query.graphde 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:developBuild
npx medusa plugin:buildPublicar en NPM
npm version patch # o minor, major
npx medusa plugin:build
npm publishEstructura 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 workflowsCompatibilidad
- Medusa v2.17.2+
- Node.js >= 20
- PostgreSQL
Licencia
MIT
Autor
jsm406
Credits
Inspirado en medusa-products-bought-together de RSC-Labs.
