diff --git a/src/app/api/webhooks/saleor/route.ts b/src/app/api/webhooks/saleor/route.ts index 15d532f..f49c978 100644 --- a/src/app/api/webhooks/saleor/route.ts +++ b/src/app/api/webhooks/saleor/route.ts @@ -1,30 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; -import crypto from "crypto"; -import { sendEmailToCustomer, sendEmailToAdmin } from "@/lib/resend"; -import { OrderConfirmation } from "@/emails/OrderConfirmation"; -import { OrderShipped } from "@/emails/OrderShipped"; -import { OrderCancelled } from "@/emails/OrderCancelled"; -import { OrderPaid } from "@/emails/OrderPaid"; -import { OpenPanel } from "@openpanel/nextjs"; +import { orderNotificationService } from "@/lib/services/OrderNotificationService"; +import { analyticsService } from "@/lib/services/AnalyticsService"; -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 +// Saleor webhook payload interfaces (snake_case) interface SaleorLineItemPayload { id: string; product_name: string; @@ -56,29 +34,24 @@ interface SaleorOrderPayload { lines: SaleorLineItemPayload[]; total_gross_amount: string; shipping_price_gross_amount?: string; - channel: { - currency_code: string; - }; - currency?: string; // Fallback for line items + channel: { currency_code: string }; + currency?: string; language_code?: string; metadata?: Record; } -// Internal camelCase interfaces for our code -interface SaleorLineItem { +// Internal camelCase interfaces +interface OrderItem { id: string; productName: string; variantName?: string; quantity: number; totalPrice: { - gross: { - amount: number; - currency: string; - }; + gross: { amount: number; currency: string }; }; } -interface SaleorAddress { +interface OrderAddress { firstName?: string; lastName?: string; streetAddress1?: string; @@ -89,30 +62,15 @@ interface SaleorAddress { phone?: string; } -interface SaleorOrder { +interface Order { id: string; number: string; userEmail: string; - user?: { - firstName?: string; - lastName?: string; - email?: string; - }; - billingAddress?: SaleorAddress; - shippingAddress?: SaleorAddress; - lines: SaleorLineItem[]; - total: { - gross: { - amount: number; - currency: string; - }; - }; - shippingPrice?: { - gross: { - amount: number; - currency: string; - }; - }; + 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 }>; } @@ -125,15 +83,8 @@ const SUPPORTED_EVENTS = [ "ORDER_FULFILLED", ]; -const LANGUAGE_CODE_MAP: Record = { - SR: "sr", - EN: "en", - DE: "de", - FR: "fr", -}; - -// Convert Saleor snake_case payload to camelCase -function convertPayloadToOrder(payload: SaleorOrderPayload): SaleorOrder { +// Convert Saleor payload to internal format +function convertPayloadToOrder(payload: SaleorOrderPayload): Order { return { id: payload.id, number: String(payload.number), @@ -141,7 +92,6 @@ function convertPayloadToOrder(payload: SaleorOrderPayload): SaleorOrder { user: payload.first_name || payload.last_name ? { firstName: payload.first_name, lastName: payload.last_name, - email: payload.user_email, } : undefined, billingAddress: payload.billing_address ? { firstName: payload.billing_address.first_name, @@ -181,338 +131,85 @@ function convertPayloadToOrder(payload: SaleorOrderPayload): SaleorOrder { currency: payload.channel.currency_code, }, }, - shippingPrice: payload.shipping_price_gross_amount ? { - gross: { - amount: parseInt(payload.shipping_price_gross_amount), - currency: payload.channel.currency_code, - }, - } : undefined, languageCode: payload.language_code?.toUpperCase(), metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined, }; } -function getCustomerLanguage(order: SaleorOrder): string { - if (order.languageCode && LANGUAGE_CODE_MAP[order.languageCode]) { - return LANGUAGE_CODE_MAP[order.languageCode]; - } - if (order.metadata) { - const langMeta = order.metadata.find((m) => m.key === "language"); - if (langMeta && LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]) { - return LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]; - } - } - return "en"; +// Extract tracking number from metadata +function getTrackingInfo(order: Order): { trackingNumber?: string; trackingUrl?: string } { + if (!order.metadata) return {}; + + const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber"); + const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl"); + + return { + trackingNumber: trackingMeta?.value, + trackingUrl: trackingUrlMeta?.value, + }; } -function formatPrice(amount: number, currency: string): string { - return new Intl.NumberFormat("sr-RS", { - style: "currency", - currency: currency, - }).format(amount); +// Extract cancellation reason from metadata +function getCancellationReason(order: Order): string | undefined { + if (!order.metadata) return undefined; + const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason"); + return reasonMeta?.value; } -function formatAddress(address?: SaleorAddress): string { - if (!address) return ""; - const parts = [ - address.firstName, - address.lastName, - address.streetAddress1, - address.streetAddress2, - address.postalCode, - address.city, - address.country, - ].filter(Boolean); - return parts.join(", "); -} +// Webhook handlers +async function handleOrderConfirmed(order: Order, eventType: string) { + const itemCount = order.lines.reduce((sum, line) => sum + line.quantity, 0); -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 + // Send customer email only for ORDER_CONFIRMED (not ORDER_CREATED) if (eventType === "ORDER_CONFIRMED") { - await sendEmailToCustomer({ - to: customerEmail, - subject: - language === "sr" - ? `Potvrda narudžbine #${order.number}` - : language === "de" - ? `Bestellbestätigung #${order.number}` - : language === "fr" - ? `Confirmation de commande #${order.number}` - : `Order Confirmation #${order.number}`, - react: OrderConfirmation({ - language, - orderId: order.id, - orderNumber: order.number, - customerEmail, - customerName, - items: parseOrderItems(order.lines, currency), - total: formatPrice(order.total.gross.amount, currency), - shippingAddress: formatAddress(order.shippingAddress), - siteUrl: SITE_URL, - }), - language, - idempotencyKey: `order-confirmed/${order.id}`, + await orderNotificationService.sendOrderConfirmation(order); + + // Track revenue and order analytics only on ORDER_CONFIRMED (not ORDER_CREATED) + // This prevents duplicate tracking when both events fire for the same order + analyticsService.trackOrderReceived({ + orderId: order.id, + orderNumber: order.number, + total: order.total.gross.amount, + currency: order.total.gross.currency, + itemCount, + customerEmail: order.userEmail, + eventType, + }); + + analyticsService.trackRevenue({ + amount: order.total.gross.amount, + currency: order.total.gross.currency, + orderId: order.id, + orderNumber: order.number, }); } - // Track order in OpenPanel - op.track("order_received", { - order_id: order.id, - order_number: order.number, - total: order.total.gross.amount, - currency: order.total.gross.currency, - item_count: order.lines.reduce((sum, line) => sum + line.quantity, 0), - customer_email: customerEmail, - event_type: eventType, - }); - - // Track revenue - op.track("revenue", { - amount: order.total.gross.amount, - currency: order.total.gross.currency, - }); - - // Always send admin notification for both ORDER_CREATED and ORDER_CONFIRMED - await sendEmailToAdmin({ - subject: `🎉 New Order #${order.number} - ${formatPrice(order.total.gross.amount, currency)}`, - react: OrderConfirmation({ - language: "en", - orderId: order.id, - orderNumber: order.number, - customerEmail, - customerName, - items: parseOrderItems(order.lines, currency), - total: formatPrice(order.total.gross.amount, currency), - shippingAddress: formatAddress(order.shippingAddress), - billingAddress: formatAddress(order.billingAddress), - phone, - siteUrl: SITE_URL, - dashboardUrl: DASHBOARD_URL, - isAdmin: true, - }), - eventType: "ORDER_CONFIRMED", - orderId: order.id, - }); - - // Track order in OpenPanel - op.track("order_received", { - order_id: order.id, - order_number: order.number, - total: order.total.gross.amount, - currency: order.total.gross.currency, - item_count: order.lines.reduce((sum, line) => sum + line.quantity, 0), - customer_email: customerEmail, - event_type: eventType, - }); - - // Track revenue using OpenPanel's revenue method - op.revenue(order.total.gross.amount, { - currency: order.total.gross.currency, - order_id: order.id, - order_number: order.number, - }); + // Send admin notification for both events + await orderNotificationService.sendOrderConfirmationToAdmin(order); } -async function handleOrderFulfilled(order: SaleorOrder) { - const language = getCustomerLanguage(order); - const currency = order.total.gross.currency; - const customerName = getCustomerName(order); - const customerEmail = order.userEmail; +async function handleOrderFulfilled(order: Order) { + const { trackingNumber, trackingUrl } = getTrackingInfo(order); - let trackingNumber: string | undefined; - let trackingUrl: string | undefined; - - if (order.metadata) { - const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber"); - if (trackingMeta) { - trackingNumber = trackingMeta.value; - } - const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl"); - if (trackingUrlMeta) { - trackingUrl = trackingUrlMeta.value; - } - } - - await sendEmailToCustomer({ - to: customerEmail, - subject: - language === "sr" - ? `Vaša narudžbina #${order.number} je poslata!` - : language === "de" - ? `Ihre Bestellung #${order.number} wurde versendet!` - : language === "fr" - ? `Votre commande #${order.number} a été expédiée!` - : `Your Order #${order.number} Has Shipped!`, - react: OrderShipped({ - language, - orderId: order.id, - orderNumber: order.number, - customerName, - items: parseOrderItems(order.lines, currency), - trackingNumber, - trackingUrl, - siteUrl: SITE_URL, - }), - language, - idempotencyKey: `order-fulfilled/${order.id}`, - }); - - await sendEmailToAdmin({ - subject: `Order Shipped #${order.number} - ${customerName}`, - react: OrderShipped({ - language: "en", - orderId: order.id, - orderNumber: order.number, - customerName, - items: parseOrderItems(order.lines, currency), - trackingNumber, - trackingUrl, - siteUrl: SITE_URL, - }), - eventType: "ORDER_FULFILLED", - orderId: order.id, - }); + await orderNotificationService.sendOrderShipped(order, trackingNumber, trackingUrl); + await orderNotificationService.sendOrderShippedToAdmin(order, trackingNumber, trackingUrl); } -async function handleOrderCancelled(order: SaleorOrder) { - const language = getCustomerLanguage(order); - const currency = order.total.gross.currency; - const customerName = getCustomerName(order); - const customerEmail = order.userEmail; +async function handleOrderCancelled(order: Order) { + const reason = getCancellationReason(order); - let reason: string | undefined; - if (order.metadata) { - const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason"); - if (reasonMeta) { - reason = reasonMeta.value; - } - } - - await sendEmailToCustomer({ - to: customerEmail, - subject: - language === "sr" - ? `Vaša narudžbina #${order.number} je otkazana` - : language === "de" - ? `Ihre Bestellung #${order.number} wurde storniert` - : language === "fr" - ? `Votre commande #${order.number} a été annulée` - : `Your Order #${order.number} Has Been Cancelled`, - react: OrderCancelled({ - language, - orderId: order.id, - orderNumber: order.number, - customerName, - items: parseOrderItems(order.lines, currency), - total: formatPrice(order.total.gross.amount, currency), - reason, - siteUrl: SITE_URL, - }), - language, - idempotencyKey: `order-cancelled/${order.id}`, - }); - - await sendEmailToAdmin({ - subject: `Order Cancelled #${order.number} - ${customerName}`, - react: OrderCancelled({ - language: "en", - orderId: order.id, - orderNumber: order.number, - customerName, - items: parseOrderItems(order.lines, currency), - total: formatPrice(order.total.gross.amount, currency), - reason, - siteUrl: SITE_URL, - }), - eventType: "ORDER_CANCELLED", - orderId: order.id, - }); + await orderNotificationService.sendOrderCancelled(order, reason); + await orderNotificationService.sendOrderCancelledToAdmin(order, reason); } -async function handleOrderFullyPaid(order: SaleorOrder) { - const language = getCustomerLanguage(order); - const currency = order.total.gross.currency; - const customerName = getCustomerName(order); - const customerEmail = order.userEmail; - - await sendEmailToCustomer({ - to: customerEmail, - subject: - language === "sr" - ? `Plaćanje za narudžbinu #${order.number} je primljeno!` - : language === "de" - ? `Zahlung für Bestellung #${order.number} erhalten!` - : language === "fr" - ? `Paiement reçu pour la commande #${order.number}!` - : `Payment Received for Order #${order.number}!`, - react: OrderPaid({ - language, - orderId: order.id, - orderNumber: order.number, - customerName, - items: parseOrderItems(order.lines, currency), - total: formatPrice(order.total.gross.amount, currency), - siteUrl: SITE_URL, - }), - language, - idempotencyKey: `order-paid/${order.id}`, - }); - - await sendEmailToAdmin({ - subject: `Payment Received #${order.number} - ${customerName} - ${formatPrice(order.total.gross.amount, currency)}`, - react: OrderPaid({ - language: "en", - orderId: order.id, - orderNumber: order.number, - customerName, - items: parseOrderItems(order.lines, currency), - total: formatPrice(order.total.gross.amount, currency), - siteUrl: SITE_URL, - }), - eventType: "ORDER_FULLY_PAID", - orderId: order.id, - }); +async function handleOrderFullyPaid(order: Order) { + await orderNotificationService.sendOrderPaid(order); + await orderNotificationService.sendOrderPaidToAdmin(order); } -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"); - } +// Main webhook processor +async function processWebhook(event: string, order: Order) { + console.log(`Processing webhook event: ${event} for order ${order.id}`); switch (event) { case "ORDER_CREATED": @@ -537,54 +234,47 @@ export async function POST(request: NextRequest) { try { console.log("=== WEBHOOK RECEIVED ==="); console.log("Timestamp:", new Date().toISOString()); - + const body = await request.json(); const headers = request.headers; const event = headers.get("saleor-event") as string; const domain = headers.get("saleor-domain"); - const signature = headers.get("saleor-signature"); - const apiUrl = headers.get("saleor-api-url"); console.log(`Received webhook: ${event} from ${domain}`); - 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; if (Array.isArray(body) && body.length > 0) { - // Legacy format: array with order objects directly orderPayload = body[0] as SaleorOrderPayload; } else if (body.data && Array.isArray(body.data)) { - // Subscription format: { data: [...] } orderPayload = body.data[0] as SaleorOrderPayload; } - + if (!orderPayload) { console.error("No order found in webhook payload"); return NextResponse.json({ error: "No order in payload" }, { status: 400 }); } - - console.log("Order ID:", orderPayload.id); - console.log("Order number:", orderPayload.number); - console.log("User email:", orderPayload.user_email); + + console.log("Order:", { + id: orderPayload.id, + number: orderPayload.number, + email: orderPayload.user_email, + }); if (!event) { return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 }); } - // Normalize event to uppercase for comparison const normalizedEvent = event.toUpperCase(); - + if (!SUPPORTED_EVENTS.includes(normalizedEvent)) { - console.log(`Event ${event} (normalized: ${normalizedEvent}) not supported, skipping`); + console.log(`Event ${event} not supported, skipping`); return NextResponse.json({ success: true, message: "Event not supported" }); } - // Convert snake_case payload to camelCase const order = convertPayloadToOrder(orderPayload); - - await handleSaleorWebhook(normalizedEvent, { order }); + await processWebhook(normalizedEvent, order); return NextResponse.json({ success: true }); } catch (error) { diff --git a/src/app/api/webhooks/saleor/utils.ts b/src/app/api/webhooks/saleor/utils.ts new file mode 100644 index 0000000..527a4bd --- /dev/null +++ b/src/app/api/webhooks/saleor/utils.ts @@ -0,0 +1,6 @@ +export function formatPrice(amount: number, currency: string): string { + return new Intl.NumberFormat("sr-RS", { + style: "currency", + currency: currency, + }).format(amount); +} diff --git a/src/lib/services/AnalyticsService.ts b/src/lib/services/AnalyticsService.ts new file mode 100644 index 0000000..d47e7cd --- /dev/null +++ b/src/lib/services/AnalyticsService.ts @@ -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 { + 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 { + 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): Promise { + 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; diff --git a/src/lib/services/OrderNotificationService.ts b/src/lib/services/OrderNotificationService.ts new file mode 100644 index 0000000..0b6a032 --- /dev/null +++ b/src/lib/services/OrderNotificationService.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + 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 = { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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;