Compare commits

..
Author SHA1 Message Date
Unchained 4d428b3ff0 Merge branch 'dev'
Build and Deploy / build (push) Has been cancelled
2026-03-25 19:58:36 +02:00
Unchained 9c2e4e1383 fix(webhook): remove incorrect /100 division from formatPrice
Build and Deploy / build (push) Has been cancelled
Saleor stores amounts as actual currency values (e.g., 5479 RSD),
not as cents (e.g., 547900). The formatPrice function was incorrectly
dividing by 100, causing prices like 5479 RSD to display as 55 RSD.
2026-03-25 19:50:39 +02:00
Unchained d0e3ee3201 fix(k8s): add OpenPanel env vars to runtime container
Build and Deploy / build (push) Has been cancelled
Add NEXT_PUBLIC_OPENPANEL_CLIENT_ID, OPENPANEL_CLIENT_SECRET, and
OPENPANEL_API_URL to the storefront runtime container for server-side
tracking to work properly.
2026-03-25 19:30:28 +02:00
4 changed files with 395 additions and 527 deletions
+395 -85
View File
@@ -1,8 +1,30 @@
import { NextRequest, NextResponse } from "next/server";
import { orderNotificationService } from "@/lib/services/OrderNotificationService";
import { analyticsService } from "@/lib/services/AnalyticsService";
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";
import { OpenPanel } from "@openpanel/nextjs";
// Saleor webhook payload interfaces (snake_case)
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
const DASHBOARD_URL = process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com";
// Initialize OpenPanel for server-side tracking
const op = new OpenPanel({
clientId: process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || "",
clientSecret: process.env.OPENPANEL_CLIENT_SECRET || "",
apiUrl: process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api",
});
interface SaleorWebhookHeaders {
"saleor-event": string;
"saleor-domain": string;
"saleor-signature"?: string;
"saleor-api-url": string;
}
// Saleor sends snake_case in webhook payloads
interface SaleorLineItemPayload {
id: string;
product_name: string;
@@ -34,24 +56,29 @@ interface SaleorOrderPayload {
lines: SaleorLineItemPayload[];
total_gross_amount: string;
shipping_price_gross_amount?: string;
channel: { currency_code: string };
currency?: string;
channel: {
currency_code: string;
};
currency?: string; // Fallback for line items
language_code?: string;
metadata?: Record<string, string>;
}
// Internal camelCase interfaces
interface OrderItem {
// Internal camelCase interfaces for our code
interface SaleorLineItem {
id: string;
productName: string;
variantName?: string;
quantity: number;
totalPrice: {
gross: { amount: number; currency: string };
gross: {
amount: number;
currency: string;
};
};
}
interface OrderAddress {
interface SaleorAddress {
firstName?: string;
lastName?: string;
streetAddress1?: string;
@@ -62,15 +89,30 @@ interface OrderAddress {
phone?: string;
}
interface Order {
interface SaleorOrder {
id: string;
number: string;
userEmail: string;
user?: { firstName?: string; lastName?: string };
billingAddress?: OrderAddress;
shippingAddress?: OrderAddress;
lines: OrderItem[];
total: { gross: { amount: number; currency: 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 }>;
}
@@ -83,8 +125,15 @@ const SUPPORTED_EVENTS = [
"ORDER_FULFILLED",
];
// Convert Saleor payload to internal format
function convertPayloadToOrder(payload: SaleorOrderPayload): Order {
const LANGUAGE_CODE_MAP: Record<string, string> = {
SR: "sr",
EN: "en",
DE: "de",
FR: "fr",
};
// Convert Saleor snake_case payload to camelCase
function convertPayloadToOrder(payload: SaleorOrderPayload): SaleorOrder {
return {
id: payload.id,
number: String(payload.number),
@@ -92,6 +141,7 @@ function convertPayloadToOrder(payload: SaleorOrderPayload): Order {
user: payload.first_name || payload.last_name ? {
firstName: payload.first_name,
lastName: payload.last_name,
email: payload.user_email,
} : undefined,
billingAddress: payload.billing_address ? {
firstName: payload.billing_address.first_name,
@@ -131,85 +181,338 @@ function convertPayloadToOrder(payload: SaleorOrderPayload): Order {
currency: payload.channel.currency_code,
},
},
shippingPrice: payload.shipping_price_gross_amount ? {
gross: {
amount: parseInt(payload.shipping_price_gross_amount),
currency: payload.channel.currency_code,
},
} : undefined,
languageCode: payload.language_code?.toUpperCase(),
metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined,
};
}
// Extract tracking number from metadata
function getTrackingInfo(order: Order): { trackingNumber?: string; trackingUrl?: string } {
if (!order.metadata) return {};
const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber");
const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl");
return {
trackingNumber: trackingMeta?.value,
trackingUrl: trackingUrlMeta?.value,
};
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";
}
// Extract cancellation reason from metadata
function getCancellationReason(order: Order): string | undefined {
if (!order.metadata) return undefined;
const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason");
return reasonMeta?.value;
function formatPrice(amount: number, currency: string): string {
return new Intl.NumberFormat("sr-RS", {
style: "currency",
currency: currency,
}).format(amount);
}
// Webhook handlers
async function handleOrderConfirmed(order: Order, eventType: string) {
const itemCount = order.lines.reduce((sum, line) => sum + line.quantity, 0);
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(", ");
}
// Send customer email only for ORDER_CONFIRMED (not ORDER_CREATED)
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, eventType: string) {
const language = getCustomerLanguage(order);
const currency = order.total.gross.currency;
const customerName = getCustomerName(order);
const customerEmail = order.userEmail;
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
// Only send customer email for ORDER_CONFIRMED, not ORDER_CREATED
// This prevents duplicate emails when both events fire
if (eventType === "ORDER_CONFIRMED") {
await orderNotificationService.sendOrderConfirmation(order);
// Track revenue and order analytics only on ORDER_CONFIRMED (not ORDER_CREATED)
// This prevents duplicate tracking when both events fire for the same order
analyticsService.trackOrderReceived({
orderId: order.id,
orderNumber: order.number,
total: order.total.gross.amount,
currency: order.total.gross.currency,
itemCount,
customerEmail: order.userEmail,
eventType,
});
analyticsService.trackRevenue({
amount: order.total.gross.amount,
currency: order.total.gross.currency,
orderId: order.id,
orderNumber: order.number,
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),
siteUrl: SITE_URL,
}),
language,
idempotencyKey: `order-confirmed/${order.id}`,
});
}
// Send admin notification for both events
await orderNotificationService.sendOrderConfirmationToAdmin(order);
// Track order in OpenPanel
op.track("order_received", {
order_id: order.id,
order_number: order.number,
total: order.total.gross.amount,
currency: order.total.gross.currency,
item_count: order.lines.reduce((sum, line) => sum + line.quantity, 0),
customer_email: customerEmail,
event_type: eventType,
});
// Track revenue
op.track("revenue", {
amount: order.total.gross.amount,
currency: order.total.gross.currency,
});
// Always send admin notification for both ORDER_CREATED and ORDER_CONFIRMED
await sendEmailToAdmin({
subject: `🎉 New Order #${order.number} - ${formatPrice(order.total.gross.amount, currency)}`,
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),
billingAddress: formatAddress(order.billingAddress),
phone,
siteUrl: SITE_URL,
dashboardUrl: DASHBOARD_URL,
isAdmin: true,
}),
eventType: "ORDER_CONFIRMED",
orderId: order.id,
});
// Track order in OpenPanel
op.track("order_received", {
order_id: order.id,
order_number: order.number,
total: order.total.gross.amount,
currency: order.total.gross.currency,
item_count: order.lines.reduce((sum, line) => sum + line.quantity, 0),
customer_email: customerEmail,
event_type: eventType,
});
// Track revenue using OpenPanel's revenue method
op.revenue(order.total.gross.amount, {
currency: order.total.gross.currency,
order_id: order.id,
order_number: order.number,
});
}
async function handleOrderFulfilled(order: Order) {
const { trackingNumber, trackingUrl } = getTrackingInfo(order);
async function handleOrderFulfilled(order: SaleorOrder) {
const language = getCustomerLanguage(order);
const currency = order.total.gross.currency;
const customerName = getCustomerName(order);
const customerEmail = order.userEmail;
await orderNotificationService.sendOrderShipped(order, trackingNumber, trackingUrl);
await orderNotificationService.sendOrderShippedToAdmin(order, trackingNumber, trackingUrl);
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,
siteUrl: SITE_URL,
}),
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,
siteUrl: SITE_URL,
}),
eventType: "ORDER_FULFILLED",
orderId: order.id,
});
}
async function handleOrderCancelled(order: Order) {
const reason = getCancellationReason(order);
async function handleOrderCancelled(order: SaleorOrder) {
const language = getCustomerLanguage(order);
const currency = order.total.gross.currency;
const customerName = getCustomerName(order);
const customerEmail = order.userEmail;
await orderNotificationService.sendOrderCancelled(order, reason);
await orderNotificationService.sendOrderCancelledToAdmin(order, reason);
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,
siteUrl: SITE_URL,
}),
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,
siteUrl: SITE_URL,
}),
eventType: "ORDER_CANCELLED",
orderId: order.id,
});
}
async function handleOrderFullyPaid(order: Order) {
await orderNotificationService.sendOrderPaid(order);
await orderNotificationService.sendOrderPaidToAdmin(order);
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),
siteUrl: SITE_URL,
}),
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),
siteUrl: SITE_URL,
}),
eventType: "ORDER_FULLY_PAID",
orderId: order.id,
});
}
// Main webhook processor
async function processWebhook(event: string, order: Order) {
console.log(`Processing webhook event: ${event} for order ${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_CREATED":
@@ -234,47 +537,54 @@ export async function POST(request: NextRequest) {
try {
console.log("=== WEBHOOK RECEIVED ===");
console.log("Timestamp:", new Date().toISOString());
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}`);
// Parse payload
console.log("Headers:", { event, domain, apiUrl, hasSignature: !!signature });
console.log("Payload:", JSON.stringify(body).substring(0, 500));
// Handle Saleor legacy webhook payload format (array with snake_case fields)
let orderPayload: SaleorOrderPayload | null = null;
if (Array.isArray(body) && body.length > 0) {
// Legacy format: array with order objects directly
orderPayload = body[0] as SaleorOrderPayload;
} else if (body.data && Array.isArray(body.data)) {
// Subscription format: { data: [...] }
orderPayload = body.data[0] as SaleorOrderPayload;
}
if (!orderPayload) {
console.error("No order found in webhook payload");
return NextResponse.json({ error: "No order in payload" }, { status: 400 });
}
console.log("Order:", {
id: orderPayload.id,
number: orderPayload.number,
email: orderPayload.user_email,
});
console.log("Order ID:", orderPayload.id);
console.log("Order number:", orderPayload.number);
console.log("User email:", orderPayload.user_email);
if (!event) {
return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 });
}
// Normalize event to uppercase for comparison
const normalizedEvent = event.toUpperCase();
if (!SUPPORTED_EVENTS.includes(normalizedEvent)) {
console.log(`Event ${event} not supported, skipping`);
console.log(`Event ${event} (normalized: ${normalizedEvent}) not supported, skipping`);
return NextResponse.json({ success: true, message: "Event not supported" });
}
// Convert snake_case payload to camelCase
const order = convertPayloadToOrder(orderPayload);
await processWebhook(normalizedEvent, order);
await handleSaleorWebhook(normalizedEvent, { order });
return NextResponse.json({ success: true });
} catch (error) {
-6
View File
@@ -1,6 +0,0 @@
export function formatPrice(amount: number, currency: string): string {
return new Intl.NumberFormat("sr-RS", {
style: "currency",
currency: currency,
}).format(amount);
}
-79
View File
@@ -1,79 +0,0 @@
import { OpenPanel } from "@openpanel/nextjs";
// Initialize OpenPanel for server-side tracking
const op = new OpenPanel({
clientId: process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || "",
clientSecret: process.env.OPENPANEL_CLIENT_SECRET || "",
apiUrl: process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api",
});
export interface OrderAnalyticsData {
orderId: string;
orderNumber: string;
total: number;
currency: string;
itemCount: number;
customerEmail: string;
eventType: string;
}
export interface RevenueData {
amount: number;
currency: string;
orderId: string;
orderNumber: string;
}
class AnalyticsService {
private static instance: AnalyticsService;
static getInstance(): AnalyticsService {
if (!AnalyticsService.instance) {
AnalyticsService.instance = new AnalyticsService();
}
return AnalyticsService.instance;
}
async trackOrderReceived(data: OrderAnalyticsData): Promise<void> {
try {
await op.track("order_received", {
order_id: data.orderId,
order_number: data.orderNumber,
total: data.total,
currency: data.currency,
item_count: data.itemCount,
customer_email: data.customerEmail,
event_type: data.eventType,
});
} catch (error) {
console.error("Failed to track order received:", error);
// Don't throw - analytics should not break the main flow
}
}
async trackRevenue(data: RevenueData): Promise<void> {
try {
console.log(`Tracking revenue: ${data.amount} ${data.currency} for order ${data.orderNumber}`);
await op.revenue(data.amount, {
currency: data.currency,
order_id: data.orderId,
order_number: data.orderNumber,
});
} catch (error) {
console.error("Failed to track revenue:", error);
// Don't throw - analytics should not break the main flow
}
}
async track(eventName: string, properties: Record<string, unknown>): Promise<void> {
try {
await op.track(eventName, properties);
} catch (error) {
console.error(`Failed to track event ${eventName}:`, error);
// Don't throw - analytics should not break the main flow
}
}
}
export const analyticsService = AnalyticsService.getInstance();
export default analyticsService;
@@ -1,357 +0,0 @@
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";
import { formatPrice } from "@/app/api/webhooks/saleor/utils";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
const DASHBOARD_URL = process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com";
// Translation helper for email subjects
function getOrderConfirmationSubject(language: string, orderNumber: string): string {
const subjects: Record<string, string> = {
sr: `Potvrda narudžbine #${orderNumber}`,
de: `Bestellbestätigung #${orderNumber}`,
fr: `Confirmation de commande #${orderNumber}`,
en: `Order Confirmation #${orderNumber}`,
};
return subjects[language] || subjects.en;
}
function getOrderShippedSubject(language: string, orderNumber: string): string {
const subjects: Record<string, string> = {
sr: `Vaša narudžbina #${orderNumber} je poslata!`,
de: `Ihre Bestellung #${orderNumber} wurde versendet!`,
fr: `Votre commande #${orderNumber} a été expédiée!`,
en: `Your Order #${orderNumber} Has Shipped!`,
};
return subjects[language] || subjects.en;
}
function getOrderCancelledSubject(language: string, orderNumber: string): string {
const subjects: Record<string, string> = {
sr: `Vaša narudžbina #${orderNumber} je otkazana`,
de: `Ihre Bestellung #${orderNumber} wurde storniert`,
fr: `Votre commande #${orderNumber} a été annulée`,
en: `Your Order #${orderNumber} Has Been Cancelled`,
};
return subjects[language] || subjects.en;
}
function getOrderPaidSubject(language: string, orderNumber: string): string {
const subjects: Record<string, string> = {
sr: `Plaćanje za narudžbinu #${orderNumber} je primljeno!`,
de: `Zahlung für Bestellung #${orderNumber} erhalten!`,
fr: `Paiement reçu pour la commande #${orderNumber}!`,
en: `Payment Received for Order #${orderNumber}!`,
};
return subjects[language] || subjects.en;
}
// Interfaces
interface OrderItem {
id: string;
productName: string;
variantName?: string;
quantity: number;
totalPrice: {
gross: {
amount: number;
currency: string;
};
};
}
interface OrderAddress {
firstName?: string;
lastName?: string;
streetAddress1?: string;
streetAddress2?: string;
city?: string;
postalCode?: string;
country?: string;
phone?: string;
}
interface Order {
id: string;
number: string;
userEmail: string;
user?: {
firstName?: string;
lastName?: string;
};
billingAddress?: OrderAddress;
shippingAddress?: OrderAddress;
lines: OrderItem[];
total: {
gross: {
amount: number;
currency: string;
};
};
languageCode?: string;
metadata?: Array<{ key: string; value: string }>;
}
interface OrderEmailItem {
id: string;
name: string;
quantity: number;
price: string;
}
class OrderNotificationService {
private static instance: OrderNotificationService;
static getInstance(): OrderNotificationService {
if (!OrderNotificationService.instance) {
OrderNotificationService.instance = new OrderNotificationService();
}
return OrderNotificationService.instance;
}
private parseOrderItems(lines: OrderItem[], currency: string): OrderEmailItem[] {
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),
}));
}
private formatAddress(address?: OrderAddress): string {
if (!address) return "";
const parts = [
address.firstName,
address.lastName,
address.streetAddress1,
address.streetAddress2,
address.city,
address.postalCode,
address.country,
].filter(Boolean);
return parts.join(", ");
}
private getCustomerName(order: Order): string {
if (order.user?.firstName || order.user?.lastName) {
return `${order.user.firstName || ""} ${order.user.lastName || ""}`.trim();
}
if (order.shippingAddress?.firstName || order.shippingAddress?.lastName) {
return `${order.shippingAddress.firstName || ""} ${order.shippingAddress.lastName || ""}`.trim();
}
return "Customer";
}
private getCustomerLanguage(order: Order): string {
const LANGUAGE_CODE_MAP: Record<string, string> = {
SR: "sr",
EN: "en",
DE: "de",
FR: "fr",
};
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";
}
async sendOrderConfirmation(order: Order): Promise<void> {
const language = this.getCustomerLanguage(order);
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
const customerEmail = order.userEmail;
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
await sendEmailToCustomer({
to: customerEmail,
subject: getOrderConfirmationSubject(language, order.number),
react: OrderConfirmation({
language,
orderId: order.id,
orderNumber: order.number,
customerEmail,
customerName,
items: this.parseOrderItems(order.lines, currency),
total: formatPrice(order.total.gross.amount, currency),
shippingAddress: this.formatAddress(order.shippingAddress),
siteUrl: SITE_URL,
}),
language,
idempotencyKey: `order-confirmed/${order.id}`,
});
}
async sendOrderConfirmationToAdmin(order: Order): Promise<void> {
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
const customerEmail = order.userEmail;
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
await sendEmailToAdmin({
subject: `🎉 New Order #${order.number} - ${formatPrice(order.total.gross.amount, currency)}`,
react: OrderConfirmation({
language: "en",
orderId: order.id,
orderNumber: order.number,
customerEmail,
customerName,
items: this.parseOrderItems(order.lines, currency),
total: formatPrice(order.total.gross.amount, currency),
shippingAddress: this.formatAddress(order.shippingAddress),
billingAddress: this.formatAddress(order.billingAddress),
phone,
siteUrl: SITE_URL,
dashboardUrl: DASHBOARD_URL,
isAdmin: true,
}),
eventType: "ORDER_CONFIRMED",
orderId: order.id,
});
}
async sendOrderShipped(order: Order, trackingNumber?: string, trackingUrl?: string): Promise<void> {
const language = this.getCustomerLanguage(order);
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
const customerEmail = order.userEmail;
await sendEmailToCustomer({
to: customerEmail,
subject: getOrderShippedSubject(language, order.number),
react: OrderShipped({
language,
orderId: order.id,
orderNumber: order.number,
customerName,
items: this.parseOrderItems(order.lines, currency),
trackingNumber,
trackingUrl,
siteUrl: SITE_URL,
}),
language,
idempotencyKey: `order-fulfilled/${order.id}`,
});
}
async sendOrderShippedToAdmin(order: Order, trackingNumber?: string, trackingUrl?: string): Promise<void> {
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
await sendEmailToAdmin({
subject: `Order Shipped #${order.number} - ${customerName}`,
react: OrderShipped({
language: "en",
orderId: order.id,
orderNumber: order.number,
customerName,
items: this.parseOrderItems(order.lines, currency),
trackingNumber,
trackingUrl,
siteUrl: SITE_URL,
}),
eventType: "ORDER_FULFILLED",
orderId: order.id,
});
}
async sendOrderCancelled(order: Order, reason?: string): Promise<void> {
const language = this.getCustomerLanguage(order);
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
const customerEmail = order.userEmail;
await sendEmailToCustomer({
to: customerEmail,
subject: getOrderCancelledSubject(language, order.number),
react: OrderCancelled({
language,
orderId: order.id,
orderNumber: order.number,
customerName,
items: this.parseOrderItems(order.lines, currency),
total: formatPrice(order.total.gross.amount, currency),
reason,
siteUrl: SITE_URL,
}),
language,
idempotencyKey: `order-cancelled/${order.id}`,
});
}
async sendOrderCancelledToAdmin(order: Order, reason?: string): Promise<void> {
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
await sendEmailToAdmin({
subject: `Order Cancelled #${order.number} - ${customerName}`,
react: OrderCancelled({
language: "en",
orderId: order.id,
orderNumber: order.number,
customerName,
items: this.parseOrderItems(order.lines, currency),
total: formatPrice(order.total.gross.amount, currency),
reason,
siteUrl: SITE_URL,
}),
eventType: "ORDER_CANCELLED",
orderId: order.id,
});
}
async sendOrderPaid(order: Order): Promise<void> {
const language = this.getCustomerLanguage(order);
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
const customerEmail = order.userEmail;
await sendEmailToCustomer({
to: customerEmail,
subject: getOrderPaidSubject(language, order.number),
react: OrderPaid({
language,
orderId: order.id,
orderNumber: order.number,
customerName,
items: this.parseOrderItems(order.lines, currency),
total: formatPrice(order.total.gross.amount, currency),
siteUrl: SITE_URL,
}),
language,
idempotencyKey: `order-paid/${order.id}`,
});
}
async sendOrderPaidToAdmin(order: Order): Promise<void> {
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
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: this.parseOrderItems(order.lines, currency),
total: formatPrice(order.total.gross.amount, currency),
siteUrl: SITE_URL,
}),
eventType: "ORDER_FULLY_PAID",
orderId: order.id,
});
}
}
export const orderNotificationService = OrderNotificationService.getInstance();
export default orderNotificationService;