trptk/lib/medusa.ts

376 lines
10 KiB
TypeScript

import Medusa from "@medusajs/js-sdk";
const MEDUSA_URL =
process.env.MEDUSA_URL ?? process.env.NEXT_PUBLIC_MEDUSA_URL ?? "http://localhost:9000";
const PUBLISHABLE_KEY =
process.env.MEDUSA_PUBLISHABLE_KEY ?? process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY ?? "";
const REGION_ID =
process.env.MEDUSA_REGION_ID ?? process.env.NEXT_PUBLIC_MEDUSA_REGION_ID ?? "";
// Create a single SDK instance at module scope.
// auth type "session" is not needed for server-side usage; we pass tokens
// via the ClientHeaders parameter where necessary.
const medusa = new Medusa({
baseUrl: MEDUSA_URL,
publishableKey: PUBLISHABLE_KEY,
auth: { type: "jwt", jwtTokenStorageMethod: "nostore" },
});
// ── Types ──────────────────────────────────────────────────────────
export type MedusaMoneyAmount = {
amount: number;
currency_code: string;
};
export type MedusaVariant = {
id: string;
title: string;
sku: string | null;
calculated_price?: {
calculated_amount: number;
currency_code: string;
};
prices?: MedusaMoneyAmount[];
};
export type MedusaProduct = {
id: string;
title: string;
handle: string;
metadata?: {
ean?: string;
catalogue_number?: string;
[key: string]: unknown;
};
variants: MedusaVariant[];
};
export type MedusaLineItem = {
id: string;
title: string;
variant_id: string;
variant: MedusaVariant;
quantity: number;
unit_price: number;
total: number;
product_title: string;
product_handle?: string;
thumbnail: string | null;
};
export type MedusaCart = {
id: string;
items: MedusaLineItem[];
total: number;
subtotal: number;
shipping_total?: number;
tax_total?: number;
discount_total?: number;
currency_code: string;
email?: string;
shipping_address?: MedusaAddress | null;
billing_address?: MedusaAddress | null;
shipping_methods?: { id: string; name: string; amount: number; shipping_option_id: string }[];
payment_collection?: MedusaPaymentCollection | null;
promotions?: { id: string; code?: string }[];
};
export type MedusaAddress = {
first_name: string;
last_name: string;
address_1: string;
address_2?: string;
city: string;
province?: string;
postal_code: string;
country_code: string;
phone?: string;
};
export type MedusaShippingOption = {
id: string;
name: string;
amount: number;
price_type: string;
};
export type MedusaPaymentSession = {
id: string;
provider_id: string;
status: string;
data?: Record<string, unknown>;
};
export type MedusaPaymentCollection = {
id: string;
status: string;
payment_sessions?: MedusaPaymentSession[];
};
export type MedusaOrder = {
id: string;
display_id: number;
email: string;
items: MedusaLineItem[];
total: number;
subtotal: number;
shipping_total: number;
tax_total: number;
currency_code: string;
shipping_address?: MedusaAddress | null;
};
// ── Helpers ────────────────────────────────────────────────────────
/** Build an authorization header object when a JWT auth token is provided. */
function authHeaders(authToken?: string): Record<string, string> {
return authToken ? { Authorization: `Bearer ${authToken}` } : {};
}
// ── Product queries ────────────────────────────────────────────────
const PRODUCT_FIELDS =
"id,title,handle,metadata,*variants,*variants.calculated_price,variants.sku";
/**
* Fetches all products from the Store API (paginated).
* Medusa v2 Store API doesn't support metadata filtering, so we fetch
* the full catalog and match server-side. The result is cached by
* Next.js via `fetch` deduplication + ISR revalidation.
*/
export async function getAllProducts(): Promise<MedusaProduct[]> {
const all: MedusaProduct[] = [];
let offset = 0;
const limit = 100;
// eslint-disable-next-line no-constant-condition
while (true) {
const data = await medusa.store.product.list({
fields: PRODUCT_FIELDS,
region_id: REGION_ID,
limit,
offset,
});
all.push(...(data.products as unknown as MedusaProduct[]));
if (all.length >= data.count) break;
offset += limit;
}
return all;
}
export async function getProductByEan(ean: string): Promise<MedusaProduct | null> {
try {
const products = await getAllProducts();
return products.find((p) => p.metadata?.ean === ean) ?? null;
} catch (err) {
console.error("[Medusa] getProductByEan failed:", err);
return null;
}
}
export async function getProductByCatalogNo(catalogNo: string): Promise<MedusaProduct | null> {
try {
const products = await getAllProducts();
return (
products.find(
(p) => p.metadata?.catalogue_number?.toUpperCase() === catalogNo.toUpperCase(),
) ?? null
);
} catch (err) {
console.error("[Medusa] getProductByCatalogNo failed:", err);
return null;
}
}
// ── Cart operations ────────────────────────────────────────────────
const CART_FIELDS = "id,items,items.*,items.variant.*,items.thumbnail,total,subtotal,discount_total,currency_code,+promotions,+promotions.code";
export async function createCart(authToken?: string): Promise<MedusaCart> {
const data = await medusa.store.cart.create(
{ region_id: REGION_ID },
{},
authHeaders(authToken),
);
return data.cart as unknown as MedusaCart;
}
export async function getCart(cartId: string): Promise<MedusaCart | null> {
try {
const data = await medusa.store.cart.retrieve(cartId, {
fields: CART_FIELDS,
});
return data.cart as unknown as MedusaCart;
} catch {
return null;
}
}
export async function addToCart(
cartId: string,
variantId: string,
quantity = 1,
): Promise<MedusaCart> {
const data = await medusa.store.cart.createLineItem(
cartId,
{ variant_id: variantId, quantity },
{ fields: CART_FIELDS },
);
return data.cart as unknown as MedusaCart;
}
export async function updateCartItem(
cartId: string,
lineItemId: string,
quantity: number,
): Promise<MedusaCart> {
const data = await medusa.store.cart.updateLineItem(
cartId,
lineItemId,
{ quantity },
{ fields: CART_FIELDS },
);
return data.cart as unknown as MedusaCart;
}
export async function removeFromCart(
cartId: string,
lineItemId: string,
): Promise<MedusaCart> {
// The SDK's deleteLineItem returns { parent: cart }.
await medusa.store.cart.deleteLineItem(cartId, lineItemId);
const cart = await getCart(cartId);
if (!cart) throw new Error("Cart not found after removing item");
return cart;
}
// ── Promotion operations ────────────────────────────────────────────
export async function applyPromoCode(
cartId: string,
code: string,
): Promise<MedusaCart> {
await medusa.client.fetch(
`/store/carts/${cartId}/promotions`,
{
method: "POST",
body: { promo_codes: [code] },
},
);
// Re-fetch with our field selection so the shape is consistent
const cart = await getCart(cartId);
if (!cart) throw new Error("Cart not found after applying promo");
return cart;
}
export async function removePromoCode(
cartId: string,
code: string,
): Promise<MedusaCart> {
await medusa.client.fetch(
`/store/carts/${cartId}/promotions`,
{
method: "DELETE",
body: { promo_codes: [code] },
},
);
const cart = await getCart(cartId);
if (!cart) throw new Error("Cart not found after removing promo");
return cart;
}
// ── Checkout operations ─────────────────────────────────────────────
export async function updateCart(
cartId: string,
data: {
email?: string;
shipping_address?: MedusaAddress;
billing_address?: MedusaAddress;
},
authToken?: string,
): Promise<MedusaCart> {
const res = await medusa.store.cart.update(
cartId,
data,
{},
authHeaders(authToken),
);
return res.cart as unknown as MedusaCart;
}
export async function getShippingOptions(
cartId: string,
): Promise<MedusaShippingOption[]> {
const data = await medusa.store.fulfillment.listCartOptions({
cart_id: cartId,
});
return data.shipping_options as unknown as MedusaShippingOption[];
}
export async function addShippingMethod(
cartId: string,
optionId: string,
): Promise<MedusaCart> {
const data = await medusa.store.cart.addShippingMethod(cartId, {
option_id: optionId,
});
return data.cart as unknown as MedusaCart;
}
export async function createPaymentCollection(
cartId: string,
): Promise<MedusaPaymentCollection> {
// The SDK's store.payment.initiatePaymentSession handles this automatically,
// but we need to keep the two-step flow for callers. Use client.fetch for
// the explicit payment-collection creation endpoint.
const data = await medusa.client.fetch<{ payment_collection: MedusaPaymentCollection }>(
"/store/payment-collections",
{
method: "POST",
body: { cart_id: cartId },
},
);
return data.payment_collection;
}
export async function initPaymentSession(
paymentCollectionId: string,
providerId: string,
sessionData?: Record<string, unknown>,
): Promise<MedusaPaymentCollection> {
const data = await medusa.client.fetch<{ payment_collection: MedusaPaymentCollection }>(
`/store/payment-collections/${paymentCollectionId}/payment-sessions`,
{
method: "POST",
body: {
provider_id: providerId,
...(sessionData ? { data: sessionData } : {}),
},
},
);
return data.payment_collection;
}
export async function completeCart(
cartId: string,
authToken?: string,
): Promise<{ type: string; order?: MedusaOrder; cart?: MedusaCart }> {
const data = await medusa.store.cart.complete(
cartId,
{},
authHeaders(authToken),
);
return data as unknown as { type: string; order?: MedusaOrder; cart?: MedusaCart };
}
export async function getOrder(orderId: string): Promise<MedusaOrder | null> {
try {
const data = await medusa.store.order.retrieve(orderId);
return data.order as unknown as MedusaOrder;
} catch {
return null;
}
}