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 { NextRequest, NextResponse } from "next/server";
import { orderNotificationService } from "@/lib/services/OrderNotificationService"; import crypto from "crypto";
import { analyticsService } from "@/lib/services/AnalyticsService"; 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 { interface SaleorLineItemPayload {
id: string; id: string;
product_name: string; product_name: string;
@@ -34,24 +56,29 @@ interface SaleorOrderPayload {
lines: SaleorLineItemPayload[]; lines: SaleorLineItemPayload[];
total_gross_amount: string; total_gross_amount: string;
shipping_price_gross_amount?: string; shipping_price_gross_amount?: string;
channel: { currency_code: string }; channel: {
currency?: string; currency_code: string;
};
currency?: string; // Fallback for line items
language_code?: string; language_code?: string;
metadata?: Record<string, string>; metadata?: Record<string, string>;
} }
// Internal camelCase interfaces // Internal camelCase interfaces for our code
interface OrderItem { interface SaleorLineItem {
id: string; id: string;
productName: string; productName: string;
variantName?: string; variantName?: string;
quantity: number; quantity: number;
totalPrice: { totalPrice: {
gross: { amount: number; currency: string }; gross: {
amount: number;
currency: string;
};
}; };
} }
interface OrderAddress { interface SaleorAddress {
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
streetAddress1?: string; streetAddress1?: string;
@@ -62,15 +89,30 @@ interface OrderAddress {
phone?: string; phone?: string;
} }
interface Order { interface SaleorOrder {
id: string; id: string;
number: string; number: string;
userEmail: string; userEmail: string;
user?: { firstName?: string; lastName?: string }; user?: {
billingAddress?: OrderAddress; firstName?: string;
shippingAddress?: OrderAddress; lastName?: string;
lines: OrderItem[]; email?: string;
total: { gross: { amount: number; currency: string } }; };
billingAddress?: SaleorAddress;
shippingAddress?: SaleorAddress;
lines: SaleorLineItem[];
total: {
gross: {
amount: number;
currency: string;
};
};
shippingPrice?: {
gross: {
amount: number;
currency: string;
};
};
languageCode?: string; languageCode?: string;
metadata?: Array<{ key: string; value: string }>; metadata?: Array<{ key: string; value: string }>;
} }
@@ -83,8 +125,15 @@ const SUPPORTED_EVENTS = [
"ORDER_FULFILLED", "ORDER_FULFILLED",
]; ];
// Convert Saleor payload to internal format const LANGUAGE_CODE_MAP: Record<string, string> = {
function convertPayloadToOrder(payload: SaleorOrderPayload): Order { SR: "sr",
EN: "en",
DE: "de",
FR: "fr",
};
// Convert Saleor snake_case payload to camelCase
function convertPayloadToOrder(payload: SaleorOrderPayload): SaleorOrder {
return { return {
id: payload.id, id: payload.id,
number: String(payload.number), number: String(payload.number),
@@ -92,6 +141,7 @@ function convertPayloadToOrder(payload: SaleorOrderPayload): Order {
user: payload.first_name || payload.last_name ? { user: payload.first_name || payload.last_name ? {
firstName: payload.first_name, firstName: payload.first_name,
lastName: payload.last_name, lastName: payload.last_name,
email: payload.user_email,
} : undefined, } : undefined,
billingAddress: payload.billing_address ? { billingAddress: payload.billing_address ? {
firstName: payload.billing_address.first_name, firstName: payload.billing_address.first_name,
@@ -131,85 +181,338 @@ function convertPayloadToOrder(payload: SaleorOrderPayload): Order {
currency: payload.channel.currency_code, 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(), languageCode: payload.language_code?.toUpperCase(),
metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined, metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined,
}; };
} }
// Extract tracking number from metadata function getCustomerLanguage(order: SaleorOrder): string {
function getTrackingInfo(order: Order): { trackingNumber?: string; trackingUrl?: string } { if (order.languageCode && LANGUAGE_CODE_MAP[order.languageCode]) {
if (!order.metadata) return {}; return LANGUAGE_CODE_MAP[order.languageCode];
}
const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber"); if (order.metadata) {
const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl"); const langMeta = order.metadata.find((m) => m.key === "language");
if (langMeta && LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]) {
return { return LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()];
trackingNumber: trackingMeta?.value, }
trackingUrl: trackingUrlMeta?.value, }
}; return "en";
} }
// Extract cancellation reason from metadata function formatPrice(amount: number, currency: string): string {
function getCancellationReason(order: Order): string | undefined { return new Intl.NumberFormat("sr-RS", {
if (!order.metadata) return undefined; style: "currency",
const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason"); currency: currency,
return reasonMeta?.value; }).format(amount);
} }
// Webhook handlers function formatAddress(address?: SaleorAddress): string {
async function handleOrderConfirmed(order: Order, eventType: string) { if (!address) return "";
const itemCount = order.lines.reduce((sum, line) => sum + line.quantity, 0); 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") { if (eventType === "ORDER_CONFIRMED") {
await orderNotificationService.sendOrderConfirmation(order); await sendEmailToCustomer({
to: customerEmail,
// Track revenue and order analytics only on ORDER_CONFIRMED (not ORDER_CREATED) subject:
// This prevents duplicate tracking when both events fire for the same order language === "sr"
analyticsService.trackOrderReceived({ ? `Potvrda narudžbine #${order.number}`
orderId: order.id, : language === "de"
orderNumber: order.number, ? `Bestellbestätigung #${order.number}`
total: order.total.gross.amount, : language === "fr"
currency: order.total.gross.currency, ? `Confirmation de commande #${order.number}`
itemCount, : `Order Confirmation #${order.number}`,
customerEmail: order.userEmail, react: OrderConfirmation({
eventType, language,
}); orderId: order.id,
orderNumber: order.number,
analyticsService.trackRevenue({ customerEmail,
amount: order.total.gross.amount, customerName,
currency: order.total.gross.currency, items: parseOrderItems(order.lines, currency),
orderId: order.id, total: formatPrice(order.total.gross.amount, currency),
orderNumber: order.number, shippingAddress: formatAddress(order.shippingAddress),
siteUrl: SITE_URL,
}),
language,
idempotencyKey: `order-confirmed/${order.id}`,
}); });
} }
// Send admin notification for both events // Track order in OpenPanel
await orderNotificationService.sendOrderConfirmationToAdmin(order); 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) { async function handleOrderFulfilled(order: SaleorOrder) {
const { trackingNumber, trackingUrl } = getTrackingInfo(order); const language = getCustomerLanguage(order);
const currency = order.total.gross.currency;
const customerName = getCustomerName(order);
const customerEmail = order.userEmail;
await orderNotificationService.sendOrderShipped(order, trackingNumber, trackingUrl); let trackingNumber: string | undefined;
await orderNotificationService.sendOrderShippedToAdmin(order, trackingNumber, trackingUrl); 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) { async function handleOrderCancelled(order: SaleorOrder) {
const reason = getCancellationReason(order); const language = getCustomerLanguage(order);
const currency = order.total.gross.currency;
const customerName = getCustomerName(order);
const customerEmail = order.userEmail;
await orderNotificationService.sendOrderCancelled(order, reason); let reason: string | undefined;
await orderNotificationService.sendOrderCancelledToAdmin(order, reason); 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) { async function handleOrderFullyPaid(order: SaleorOrder) {
await orderNotificationService.sendOrderPaid(order); const language = getCustomerLanguage(order);
await orderNotificationService.sendOrderPaidToAdmin(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 handleSaleorWebhook(
async function processWebhook(event: string, order: Order) { event: string,
console.log(`Processing webhook event: ${event} for order ${order.id}`); 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) { switch (event) {
case "ORDER_CREATED": case "ORDER_CREATED":
@@ -234,47 +537,54 @@ export async function POST(request: NextRequest) {
try { try {
console.log("=== WEBHOOK RECEIVED ==="); console.log("=== WEBHOOK RECEIVED ===");
console.log("Timestamp:", new Date().toISOString()); console.log("Timestamp:", new Date().toISOString());
const body = await request.json(); const body = await request.json();
const headers = request.headers; const headers = request.headers;
const event = headers.get("saleor-event") as string; const event = headers.get("saleor-event") as string;
const domain = headers.get("saleor-domain"); 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}`); console.log(`Received webhook: ${event} from ${domain}`);
console.log("Headers:", { event, domain, apiUrl, hasSignature: !!signature });
// Parse payload 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; let orderPayload: SaleorOrderPayload | null = null;
if (Array.isArray(body) && body.length > 0) { if (Array.isArray(body) && body.length > 0) {
// Legacy format: array with order objects directly
orderPayload = body[0] as SaleorOrderPayload; orderPayload = body[0] as SaleorOrderPayload;
} else if (body.data && Array.isArray(body.data)) { } else if (body.data && Array.isArray(body.data)) {
// Subscription format: { data: [...] }
orderPayload = body.data[0] as SaleorOrderPayload; orderPayload = body.data[0] as SaleorOrderPayload;
} }
if (!orderPayload) { if (!orderPayload) {
console.error("No order found in webhook payload"); console.error("No order found in webhook payload");
return NextResponse.json({ error: "No order in payload" }, { status: 400 }); return NextResponse.json({ error: "No order in payload" }, { status: 400 });
} }
console.log("Order:", { console.log("Order ID:", orderPayload.id);
id: orderPayload.id, console.log("Order number:", orderPayload.number);
number: orderPayload.number, console.log("User email:", orderPayload.user_email);
email: orderPayload.user_email,
});
if (!event) { if (!event) {
return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 }); return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 });
} }
// Normalize event to uppercase for comparison
const normalizedEvent = event.toUpperCase(); const normalizedEvent = event.toUpperCase();
if (!SUPPORTED_EVENTS.includes(normalizedEvent)) { 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" }); return NextResponse.json({ success: true, message: "Event not supported" });
} }
// Convert snake_case payload to camelCase
const order = convertPayloadToOrder(orderPayload); const order = convertPayloadToOrder(orderPayload);
await processWebhook(normalizedEvent, order);
await handleSaleorWebhook(normalizedEvent, { order });
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } 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;