Merge branch 'dev'
Some checks failed
Build and Deploy / build (push) Has been cancelled

This commit is contained in:
Unchained
2026-03-25 20:40:37 +02:00
4 changed files with 527 additions and 395 deletions

View File

@@ -1,30 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto"; import { orderNotificationService } from "@/lib/services/OrderNotificationService";
import { sendEmailToCustomer, sendEmailToAdmin } from "@/lib/resend"; import { analyticsService } from "@/lib/services/AnalyticsService";
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";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com"; // Saleor webhook payload interfaces (snake_case)
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;
@@ -56,29 +34,24 @@ 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: { channel: { currency_code: string };
currency_code: string; currency?: string;
};
currency?: string; // Fallback for line items
language_code?: string; language_code?: string;
metadata?: Record<string, string>; metadata?: Record<string, string>;
} }
// Internal camelCase interfaces for our code // Internal camelCase interfaces
interface SaleorLineItem { interface OrderItem {
id: string; id: string;
productName: string; productName: string;
variantName?: string; variantName?: string;
quantity: number; quantity: number;
totalPrice: { totalPrice: {
gross: { gross: { amount: number; currency: string };
amount: number;
currency: string;
};
}; };
} }
interface SaleorAddress { interface OrderAddress {
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
streetAddress1?: string; streetAddress1?: string;
@@ -89,30 +62,15 @@ interface SaleorAddress {
phone?: string; phone?: string;
} }
interface SaleorOrder { interface Order {
id: string; id: string;
number: string; number: string;
userEmail: string; userEmail: string;
user?: { user?: { firstName?: string; lastName?: string };
firstName?: string; billingAddress?: OrderAddress;
lastName?: string; shippingAddress?: OrderAddress;
email?: string; lines: OrderItem[];
}; 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 }>;
} }
@@ -125,15 +83,8 @@ const SUPPORTED_EVENTS = [
"ORDER_FULFILLED", "ORDER_FULFILLED",
]; ];
const LANGUAGE_CODE_MAP: Record<string, string> = { // Convert Saleor payload to internal format
SR: "sr", function convertPayloadToOrder(payload: SaleorOrderPayload): Order {
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),
@@ -141,7 +92,6 @@ function convertPayloadToOrder(payload: SaleorOrderPayload): SaleorOrder {
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,
@@ -181,338 +131,85 @@ function convertPayloadToOrder(payload: SaleorOrderPayload): SaleorOrder {
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,
}; };
} }
function getCustomerLanguage(order: SaleorOrder): string { // Extract tracking number from metadata
if (order.languageCode && LANGUAGE_CODE_MAP[order.languageCode]) { function getTrackingInfo(order: Order): { trackingNumber?: string; trackingUrl?: string } {
return LANGUAGE_CODE_MAP[order.languageCode]; if (!order.metadata) return {};
}
if (order.metadata) { const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber");
const langMeta = order.metadata.find((m) => m.key === "language"); const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl");
if (langMeta && LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]) {
return LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]; return {
} trackingNumber: trackingMeta?.value,
} trackingUrl: trackingUrlMeta?.value,
return "en"; };
} }
function formatPrice(amount: number, currency: string): string { // Extract cancellation reason from metadata
return new Intl.NumberFormat("sr-RS", { function getCancellationReason(order: Order): string | undefined {
style: "currency", if (!order.metadata) return undefined;
currency: currency, const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason");
}).format(amount); return reasonMeta?.value;
} }
function formatAddress(address?: SaleorAddress): string { // Webhook handlers
if (!address) return ""; async function handleOrderConfirmed(order: Order, eventType: string) {
const parts = [ const itemCount = order.lines.reduce((sum, line) => sum + line.quantity, 0);
address.firstName,
address.lastName,
address.streetAddress1,
address.streetAddress2,
address.postalCode,
address.city,
address.country,
].filter(Boolean);
return parts.join(", ");
}
function getCustomerName(order: SaleorOrder): string { // Send customer email only for ORDER_CONFIRMED (not ORDER_CREATED)
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 sendEmailToCustomer({ await orderNotificationService.sendOrderConfirmation(order);
to: customerEmail,
subject: // Track revenue and order analytics only on ORDER_CONFIRMED (not ORDER_CREATED)
language === "sr" // This prevents duplicate tracking when both events fire for the same order
? `Potvrda narudžbine #${order.number}` analyticsService.trackOrderReceived({
: language === "de" orderId: order.id,
? `Bestellbestätigung #${order.number}` orderNumber: order.number,
: language === "fr" total: order.total.gross.amount,
? `Confirmation de commande #${order.number}` currency: order.total.gross.currency,
: `Order Confirmation #${order.number}`, itemCount,
react: OrderConfirmation({ customerEmail: order.userEmail,
language, eventType,
orderId: order.id, });
orderNumber: order.number,
customerEmail, analyticsService.trackRevenue({
customerName, amount: order.total.gross.amount,
items: parseOrderItems(order.lines, currency), currency: order.total.gross.currency,
total: formatPrice(order.total.gross.amount, currency), orderId: order.id,
shippingAddress: formatAddress(order.shippingAddress), orderNumber: order.number,
siteUrl: SITE_URL,
}),
language,
idempotencyKey: `order-confirmed/${order.id}`,
}); });
} }
// Track order in OpenPanel // Send admin notification for both events
op.track("order_received", { await orderNotificationService.sendOrderConfirmationToAdmin(order);
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: SaleorOrder) { async function handleOrderFulfilled(order: Order) {
const language = getCustomerLanguage(order); const { trackingNumber, trackingUrl } = getTrackingInfo(order);
const currency = order.total.gross.currency;
const customerName = getCustomerName(order);
const customerEmail = order.userEmail;
let trackingNumber: string | undefined; await orderNotificationService.sendOrderShipped(order, trackingNumber, trackingUrl);
let trackingUrl: string | undefined; await orderNotificationService.sendOrderShippedToAdmin(order, trackingNumber, trackingUrl);
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: SaleorOrder) { async function handleOrderCancelled(order: Order) {
const language = getCustomerLanguage(order); const reason = getCancellationReason(order);
const currency = order.total.gross.currency;
const customerName = getCustomerName(order);
const customerEmail = order.userEmail;
let reason: string | undefined; await orderNotificationService.sendOrderCancelled(order, reason);
if (order.metadata) { await orderNotificationService.sendOrderCancelledToAdmin(order, reason);
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: SaleorOrder) { async function handleOrderFullyPaid(order: Order) {
const language = getCustomerLanguage(order); await orderNotificationService.sendOrderPaid(order);
const currency = order.total.gross.currency; await orderNotificationService.sendOrderPaidToAdmin(order);
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,
});
} }
async function handleSaleorWebhook( // Main webhook processor
event: string, async function processWebhook(event: string, order: Order) {
payload: { order: SaleorOrder } console.log(`Processing webhook event: ${event} for order ${order.id}`);
) {
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":
@@ -543,20 +240,14 @@ export async function POST(request: NextRequest) {
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 });
console.log("Payload:", JSON.stringify(body).substring(0, 500));
// Handle Saleor legacy webhook payload format (array with snake_case fields) // Parse payload
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;
} }
@@ -565,26 +256,25 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "No order in payload" }, { status: 400 }); return NextResponse.json({ error: "No order in payload" }, { status: 400 });
} }
console.log("Order ID:", orderPayload.id); console.log("Order:", {
console.log("Order number:", orderPayload.number); id: orderPayload.id,
console.log("User email:", orderPayload.user_email); number: orderPayload.number,
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} (normalized: ${normalizedEvent}) not supported, skipping`); console.log(`Event ${event} 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) {

View File

@@ -0,0 +1,6 @@
export function formatPrice(amount: number, currency: string): string {
return new Intl.NumberFormat("sr-RS", {
style: "currency",
currency: currency,
}).format(amount);
}

View File

@@ -0,0 +1,79 @@
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;

View File

@@ -0,0 +1,357 @@
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;