feat: Add Saleor webhook handler with Resend email integration
- Add Resend SDK for transactional emails - Create React Email templates for order events: - OrderConfirmation - OrderShipped - OrderCancelled - OrderPaid - Multi-language support (SR, EN, DE, FR) - Customer emails in their language - Admin emails in English to me@hytham.me and tamara@hytham.me - Webhook handler at /api/webhooks/saleor - Supports: ORDER_CONFIRMED, ORDER_FULLY_PAID, ORDER_CANCELLED, ORDER_FULFILLED - Add GraphQL mutation to create webhooks in Saleor - Add Resend API key to .env.local
This commit is contained in:
411
src/app/api/webhooks/saleor/route.ts
Normal file
411
src/app/api/webhooks/saleor/route.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
import { sendEmailToCustomer, sendEmailToAdmin } from "@/lib/resend";
|
||||
import { OrderConfirmation } from "@/emails/OrderConfirmation";
|
||||
import { OrderShipped } from "@/emails/OrderShipped";
|
||||
import { OrderCancelled } from "@/emails/OrderCancelled";
|
||||
import { OrderPaid } from "@/emails/OrderPaid";
|
||||
|
||||
interface SaleorWebhookHeaders {
|
||||
"saleor-event": string;
|
||||
"saleor-domain": string;
|
||||
"saleor-signature"?: string;
|
||||
"saleor-api-url": string;
|
||||
}
|
||||
|
||||
interface SaleorLineItem {
|
||||
id: string;
|
||||
productName: string;
|
||||
variantName?: string;
|
||||
quantity: number;
|
||||
quantityUnit?: string;
|
||||
totalPrice: {
|
||||
gross: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface SaleorAddress {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
streetAddress1?: string;
|
||||
streetAddress2?: string;
|
||||
city?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface SaleorOrder {
|
||||
id: string;
|
||||
number: string;
|
||||
userEmail: string;
|
||||
user?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
};
|
||||
billingAddress?: SaleorAddress;
|
||||
shippingAddress?: SaleorAddress;
|
||||
lines: SaleorLineItem[];
|
||||
total: {
|
||||
gross: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
};
|
||||
shippingPrice?: {
|
||||
gross: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
};
|
||||
languageCode?: string;
|
||||
metadata?: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
const SUPPORTED_EVENTS = [
|
||||
"ORDER_CONFIRMED",
|
||||
"ORDER_FULLY_PAID",
|
||||
"ORDER_CANCELLED",
|
||||
"ORDER_FULFILLED",
|
||||
];
|
||||
|
||||
const LANGUAGE_CODE_MAP: Record<string, string> = {
|
||||
SR: "sr",
|
||||
EN: "en",
|
||||
DE: "de",
|
||||
FR: "fr",
|
||||
};
|
||||
|
||||
function getCustomerLanguage(order: SaleorOrder): string {
|
||||
if (order.languageCode && LANGUAGE_CODE_MAP[order.languageCode]) {
|
||||
return LANGUAGE_CODE_MAP[order.languageCode];
|
||||
}
|
||||
if (order.metadata) {
|
||||
const langMeta = order.metadata.find((m) => m.key === "language");
|
||||
if (langMeta && LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]) {
|
||||
return LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()];
|
||||
}
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
function formatPrice(amount: number, currency: string): string {
|
||||
return new Intl.NumberFormat("sr-RS", {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
}).format(amount / 100);
|
||||
}
|
||||
|
||||
function formatAddress(address?: SaleorAddress): string {
|
||||
if (!address) return "";
|
||||
const parts = [
|
||||
address.firstName,
|
||||
address.lastName,
|
||||
address.streetAddress1,
|
||||
address.streetAddress2,
|
||||
address.postalCode,
|
||||
address.city,
|
||||
address.country,
|
||||
].filter(Boolean);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function getCustomerName(order: SaleorOrder): string {
|
||||
if (order.user?.firstName) {
|
||||
return `${order.user.firstName}${order.user.lastName ? ` ${order.user.lastName}` : ""}`;
|
||||
}
|
||||
if (order.billingAddress?.firstName) {
|
||||
return `${order.billingAddress.firstName}${order.billingAddress.lastName ? ` ${order.billingAddress.lastName}` : ""}`;
|
||||
}
|
||||
return "Customer";
|
||||
}
|
||||
|
||||
function parseOrderItems(lines: SaleorLineItem[], currency: string) {
|
||||
return lines.map((line) => ({
|
||||
id: line.id,
|
||||
name: line.variantName ? `${line.productName} (${line.variantName})` : line.productName,
|
||||
quantity: line.quantity,
|
||||
price: formatPrice(line.totalPrice.gross.amount, currency),
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleOrderConfirmed(order: SaleorOrder) {
|
||||
const language = getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = getCustomerName(order);
|
||||
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject:
|
||||
language === "sr"
|
||||
? `Potvrda narudžbine #${order.number}`
|
||||
: language === "de"
|
||||
? `Bestellbestätigung #${order.number}`
|
||||
: language === "fr"
|
||||
? `Confirmation de commande #${order.number}`
|
||||
: `Order Confirmation #${order.number}`,
|
||||
react: OrderConfirmation({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerEmail,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
shippingAddress: formatAddress(order.shippingAddress),
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-confirmed/${order.id}`,
|
||||
});
|
||||
|
||||
await sendEmailToAdmin({
|
||||
subject: `New Order #${order.number} - ${customerName}`,
|
||||
react: OrderConfirmation({
|
||||
language: "en",
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerEmail,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
shippingAddress: formatAddress(order.shippingAddress),
|
||||
}),
|
||||
eventType: "ORDER_CONFIRMED",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleOrderFulfilled(order: SaleorOrder) {
|
||||
const language = getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
let trackingNumber: string | undefined;
|
||||
let trackingUrl: string | undefined;
|
||||
|
||||
if (order.metadata) {
|
||||
const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber");
|
||||
if (trackingMeta) {
|
||||
trackingNumber = trackingMeta.value;
|
||||
}
|
||||
const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl");
|
||||
if (trackingUrlMeta) {
|
||||
trackingUrl = trackingUrlMeta.value;
|
||||
}
|
||||
}
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject:
|
||||
language === "sr"
|
||||
? `Vaša narudžbina #${order.number} je poslata!`
|
||||
: language === "de"
|
||||
? `Ihre Bestellung #${order.number} wurde versendet!`
|
||||
: language === "fr"
|
||||
? `Votre commande #${order.number} a été expédiée!`
|
||||
: `Your Order #${order.number} Has Shipped!`,
|
||||
react: OrderShipped({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
trackingNumber,
|
||||
trackingUrl,
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-fulfilled/${order.id}`,
|
||||
});
|
||||
|
||||
await sendEmailToAdmin({
|
||||
subject: `Order Shipped #${order.number} - ${customerName}`,
|
||||
react: OrderShipped({
|
||||
language: "en",
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
trackingNumber,
|
||||
trackingUrl,
|
||||
}),
|
||||
eventType: "ORDER_FULFILLED",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleOrderCancelled(order: SaleorOrder) {
|
||||
const language = getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
let reason: string | undefined;
|
||||
if (order.metadata) {
|
||||
const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason");
|
||||
if (reasonMeta) {
|
||||
reason = reasonMeta.value;
|
||||
}
|
||||
}
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject:
|
||||
language === "sr"
|
||||
? `Vaša narudžbina #${order.number} je otkazana`
|
||||
: language === "de"
|
||||
? `Ihre Bestellung #${order.number} wurde storniert`
|
||||
: language === "fr"
|
||||
? `Votre commande #${order.number} a été annulée`
|
||||
: `Your Order #${order.number} Has Been Cancelled`,
|
||||
react: OrderCancelled({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
reason,
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-cancelled/${order.id}`,
|
||||
});
|
||||
|
||||
await sendEmailToAdmin({
|
||||
subject: `Order Cancelled #${order.number} - ${customerName}`,
|
||||
react: OrderCancelled({
|
||||
language: "en",
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
reason,
|
||||
}),
|
||||
eventType: "ORDER_CANCELLED",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleOrderFullyPaid(order: SaleorOrder) {
|
||||
const language = getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject:
|
||||
language === "sr"
|
||||
? `Plaćanje za narudžbinu #${order.number} je primljeno!`
|
||||
: language === "de"
|
||||
? `Zahlung für Bestellung #${order.number} erhalten!`
|
||||
: language === "fr"
|
||||
? `Paiement reçu pour la commande #${order.number}!`
|
||||
: `Payment Received for Order #${order.number}!`,
|
||||
react: OrderPaid({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-paid/${order.id}`,
|
||||
});
|
||||
|
||||
await sendEmailToAdmin({
|
||||
subject: `Payment Received #${order.number} - ${customerName} - ${formatPrice(order.total.gross.amount, currency)}`,
|
||||
react: OrderPaid({
|
||||
language: "en",
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
}),
|
||||
eventType: "ORDER_FULLY_PAID",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaleorWebhook(
|
||||
event: string,
|
||||
payload: { order: SaleorOrder }
|
||||
) {
|
||||
const { order } = payload;
|
||||
|
||||
console.log(`Processing webhook event: ${event} for order ${order?.id}`);
|
||||
|
||||
if (!order || !order.id) {
|
||||
console.error("No order in payload");
|
||||
throw new Error("No order in payload");
|
||||
}
|
||||
|
||||
switch (event) {
|
||||
case "ORDER_CONFIRMED":
|
||||
await handleOrderConfirmed(order);
|
||||
break;
|
||||
case "ORDER_FULFILLED":
|
||||
await handleOrderFulfilled(order);
|
||||
break;
|
||||
case "ORDER_CANCELLED":
|
||||
await handleOrderCancelled(order);
|
||||
break;
|
||||
case "ORDER_FULLY_PAID":
|
||||
await handleOrderFullyPaid(order);
|
||||
break;
|
||||
default:
|
||||
console.log(`Unsupported event: ${event}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const headers = request.headers;
|
||||
|
||||
const event = headers.get("saleor-event") as string;
|
||||
const domain = headers.get("saleor-domain");
|
||||
const signature = headers.get("saleor-signature");
|
||||
const apiUrl = headers.get("saleor-api-url");
|
||||
|
||||
console.log(`Received webhook: ${event} from ${domain}`);
|
||||
|
||||
if (!event) {
|
||||
return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!SUPPORTED_EVENTS.includes(event)) {
|
||||
console.log(`Event ${event} not supported, skipping`);
|
||||
return NextResponse.json({ success: true, message: "Event not supported" });
|
||||
}
|
||||
|
||||
const payload = body;
|
||||
|
||||
await handleSaleorWebhook(event, payload);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Webhook processing error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error", details: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
message: "Saleor webhook endpoint is active",
|
||||
supportedEvents: SUPPORTED_EVENTS,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user