Compare commits

...
7 Commits
Author SHA1 Message Date
Unchained c98677405a Merge branch 'dev'
Build and Deploy / build (push) Has been cancelled
2026-03-25 20:40:37 +02:00
Unchained 4a63098e3e chore(analytics): add logging to verify currency being sent
Add console.log to trackRevenue to verify what currency code
is being sent to OpenPanel for debugging USD display issue.
2026-03-25 20:36:40 +02:00
Unchained 2e6668ff0d fix(webhook): prevent duplicate revenue tracking
Move analytics tracking inside ORDER_CONFIRMED conditional block
so revenue is only tracked once when order is confirmed, not twice
(once for ORDER_CREATED and once for ORDER_CONFIRMED).
2026-03-25 20:35:39 +02:00
Unchained eb9a798d40 Merge branch 'feature/saleor-emails' into dev 2026-03-25 20:26:47 +02:00
Unchained ab7dfbe48b refactor(webhook): modularize email and analytics code
Create service-oriented architecture for better maintainability:

- AnalyticsService: Centralized analytics tracking with OpenPanel
  - trackOrderReceived(), trackRevenue(), track()
  - Error handling that doesn't break main flow
  - Singleton pattern for single instance

- OrderNotificationService: Encapsulates all order email logic
  - sendOrderConfirmation() - customer + admin
  - sendOrderShipped() - with tracking info
  - sendOrderCancelled() - with reason
  - sendOrderPaid() - payment confirmation
  - Translation logic moved from webhook to service
  - Email formatting utilities encapsulated

- Webhook route refactored:
  - Reduced from 605 lines to ~250 lines
  - No business logic - only HTTP handling
  - Delegates to services for emails and analytics
  - Cleaner separation of concerns

- New utils file: formatPrice() shared between services

This prevents future bugs by:
1. Centralizing email logic in one place
2. Making code testable (services can be unit tested)
3. Easier to add new webhook handlers
4. Translation logic not mixed with HTTP code
5. Analytics failures don't break order processing
2026-03-25 20:22:55 +02:00
Unchained 319f62b923 chore(k8s): sync deployment.yaml with master - add OpenPanel env vars 2026-03-25 20:10:20 +02:00
Unchained f73f3b8576 chore(k8s): sync deployment.yaml with master - add OpenPanel env vars 2026-03-25 20:09:42 +02:00
4 changed files with 527 additions and 395 deletions
+70 -380
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"
? `Bestellbestätigung #${order.number}`
: language === "fr"
? `Confirmation de commande #${order.number}`
: `Order Confirmation #${order.number}`,
react: OrderConfirmation({
language,
orderId: order.id, orderId: order.id,
orderNumber: order.number, 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}`,
});
}
// Track order in OpenPanel
op.track("order_received", {
order_id: order.id,
order_number: order.number,
total: order.total.gross.amount, total: order.total.gross.amount,
currency: order.total.gross.currency, currency: order.total.gross.currency,
item_count: order.lines.reduce((sum, line) => sum + line.quantity, 0), itemCount,
customer_email: customerEmail, customerEmail: order.userEmail,
event_type: eventType, eventType,
}); });
// Track revenue analyticsService.trackRevenue({
op.track("revenue", {
amount: order.total.gross.amount, amount: order.total.gross.amount,
currency: order.total.gross.currency, 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, orderId: order.id,
orderNumber: order.number, 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) { // Send admin notification for both events
const language = getCustomerLanguage(order); await orderNotificationService.sendOrderConfirmationToAdmin(order);
const currency = order.total.gross.currency;
const customerName = getCustomerName(order);
const customerEmail = order.userEmail;
let trackingNumber: string | undefined;
let trackingUrl: string | undefined;
if (order.metadata) {
const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber");
if (trackingMeta) {
trackingNumber = trackingMeta.value;
}
const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl");
if (trackingUrlMeta) {
trackingUrl = trackingUrlMeta.value;
}
} }
await sendEmailToCustomer({ async function handleOrderFulfilled(order: Order) {
to: customerEmail, const { trackingNumber, trackingUrl } = getTrackingInfo(order);
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({ await orderNotificationService.sendOrderShipped(order, trackingNumber, trackingUrl);
subject: `Order Shipped #${order.number} - ${customerName}`, await orderNotificationService.sendOrderShippedToAdmin(order, trackingNumber, trackingUrl);
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({ async function handleOrderFullyPaid(order: Order) {
to: customerEmail, await orderNotificationService.sendOrderPaid(order);
subject: await orderNotificationService.sendOrderPaidToAdmin(order);
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) { // Main webhook processor
const language = getCustomerLanguage(order); async function processWebhook(event: string, order: Order) {
const currency = order.total.gross.currency; console.log(`Processing webhook event: ${event} for order ${order.id}`);
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(
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) { 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) {
+6
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);
}
+79
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;
@@ -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;