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
This commit is contained in:
@@ -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,84 @@ 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:
|
|
||||||
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}`,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
// Track analytics (fire and forget - don't await)
|
||||||
|
analyticsService.trackOrderReceived({
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: 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,
|
|
||||||
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,
|
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 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 +239,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 +255,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
src/app/api/webhooks/saleor/utils.ts
Normal file
6
src/app/api/webhooks/saleor/utils.ts
Normal 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);
|
||||||
|
}
|
||||||
78
src/lib/services/AnalyticsService.ts
Normal file
78
src/lib/services/AnalyticsService.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
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 {
|
||||||
|
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;
|
||||||
357
src/lib/services/OrderNotificationService.ts
Normal file
357
src/lib/services/OrderNotificationService.ts
Normal 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;
|
||||||
Reference in New Issue
Block a user