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,85 @@ function convertPayloadToOrder(payload: SaleorOrderPayload): SaleorOrder {
|
|||||||
currency: payload.channel.currency_code,
|
currency: payload.channel.currency_code,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
shippingPrice: payload.shipping_price_gross_amount ? {
|
|
||||||
gross: {
|
|
||||||
amount: parseInt(payload.shipping_price_gross_amount),
|
|
||||||
currency: payload.channel.currency_code,
|
|
||||||
},
|
|
||||||
} : undefined,
|
|
||||||
languageCode: payload.language_code?.toUpperCase(),
|
languageCode: payload.language_code?.toUpperCase(),
|
||||||
metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined,
|
metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCustomerLanguage(order: SaleorOrder): string {
|
// Extract tracking number from metadata
|
||||||
if (order.languageCode && LANGUAGE_CODE_MAP[order.languageCode]) {
|
function getTrackingInfo(order: Order): { trackingNumber?: string; trackingUrl?: string } {
|
||||||
return LANGUAGE_CODE_MAP[order.languageCode];
|
if (!order.metadata) return {};
|
||||||
}
|
|
||||||
if (order.metadata) {
|
const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber");
|
||||||
const langMeta = order.metadata.find((m) => m.key === "language");
|
const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl");
|
||||||
if (langMeta && LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]) {
|
|
||||||
return LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()];
|
return {
|
||||||
}
|
trackingNumber: trackingMeta?.value,
|
||||||
}
|
trackingUrl: trackingUrlMeta?.value,
|
||||||
return "en";
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPrice(amount: number, currency: string): string {
|
// Extract cancellation reason from metadata
|
||||||
return new Intl.NumberFormat("sr-RS", {
|
function getCancellationReason(order: Order): string | undefined {
|
||||||
style: "currency",
|
if (!order.metadata) return undefined;
|
||||||
currency: currency,
|
const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason");
|
||||||
}).format(amount);
|
return reasonMeta?.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAddress(address?: SaleorAddress): string {
|
// Webhook handlers
|
||||||
if (!address) return "";
|
async function handleOrderConfirmed(order: Order, eventType: string) {
|
||||||
const parts = [
|
const itemCount = order.lines.reduce((sum, line) => sum + line.quantity, 0);
|
||||||
address.firstName,
|
|
||||||
address.lastName,
|
|
||||||
address.streetAddress1,
|
|
||||||
address.streetAddress2,
|
|
||||||
address.postalCode,
|
|
||||||
address.city,
|
|
||||||
address.country,
|
|
||||||
].filter(Boolean);
|
|
||||||
return parts.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCustomerName(order: SaleorOrder): string {
|
// Send customer email only for ORDER_CONFIRMED (not ORDER_CREATED)
|
||||||
if (order.user?.firstName) {
|
|
||||||
return `${order.user.firstName}${order.user.lastName ? ` ${order.user.lastName}` : ""}`;
|
|
||||||
}
|
|
||||||
if (order.billingAddress?.firstName) {
|
|
||||||
return `${order.billingAddress.firstName}${order.billingAddress.lastName ? ` ${order.billingAddress.lastName}` : ""}`;
|
|
||||||
}
|
|
||||||
return "Customer";
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOrderItems(lines: SaleorLineItem[], currency: string) {
|
|
||||||
return lines.map((line) => ({
|
|
||||||
id: line.id,
|
|
||||||
name: line.variantName ? `${line.productName} (${line.variantName})` : line.productName,
|
|
||||||
quantity: line.quantity,
|
|
||||||
price: formatPrice(line.totalPrice.gross.amount, currency),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleOrderConfirmed(order: SaleorOrder, eventType: string) {
|
|
||||||
const language = getCustomerLanguage(order);
|
|
||||||
const currency = order.total.gross.currency;
|
|
||||||
const customerName = getCustomerName(order);
|
|
||||||
|
|
||||||
const customerEmail = order.userEmail;
|
|
||||||
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
|
|
||||||
|
|
||||||
// Only send customer email for ORDER_CONFIRMED, not ORDER_CREATED
|
|
||||||
// This prevents duplicate emails when both events fire
|
|
||||||
if (eventType === "ORDER_CONFIRMED") {
|
if (eventType === "ORDER_CONFIRMED") {
|
||||||
await sendEmailToCustomer({
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
to: customerEmail,
|
|
||||||
subject:
|
// Track revenue and order analytics only on ORDER_CONFIRMED (not ORDER_CREATED)
|
||||||
language === "sr"
|
// This prevents duplicate tracking when both events fire for the same order
|
||||||
? `Potvrda narudžbine #${order.number}`
|
analyticsService.trackOrderReceived({
|
||||||
: language === "de"
|
orderId: order.id,
|
||||||
? `Bestellbestätigung #${order.number}`
|
orderNumber: order.number,
|
||||||
: language === "fr"
|
total: order.total.gross.amount,
|
||||||
? `Confirmation de commande #${order.number}`
|
currency: order.total.gross.currency,
|
||||||
: `Order Confirmation #${order.number}`,
|
itemCount,
|
||||||
react: OrderConfirmation({
|
customerEmail: order.userEmail,
|
||||||
language,
|
eventType,
|
||||||
orderId: order.id,
|
});
|
||||||
orderNumber: order.number,
|
|
||||||
customerEmail,
|
analyticsService.trackRevenue({
|
||||||
customerName,
|
amount: order.total.gross.amount,
|
||||||
items: parseOrderItems(order.lines, currency),
|
currency: order.total.gross.currency,
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
orderId: order.id,
|
||||||
shippingAddress: formatAddress(order.shippingAddress),
|
orderNumber: order.number,
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
language,
|
|
||||||
idempotencyKey: `order-confirmed/${order.id}`,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track order in OpenPanel
|
// Send admin notification for both events
|
||||||
op.track("order_received", {
|
await orderNotificationService.sendOrderConfirmationToAdmin(order);
|
||||||
order_id: order.id,
|
|
||||||
order_number: order.number,
|
|
||||||
total: order.total.gross.amount,
|
|
||||||
currency: order.total.gross.currency,
|
|
||||||
item_count: order.lines.reduce((sum, line) => sum + line.quantity, 0),
|
|
||||||
customer_email: customerEmail,
|
|
||||||
event_type: eventType,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track revenue
|
|
||||||
op.track("revenue", {
|
|
||||||
amount: order.total.gross.amount,
|
|
||||||
currency: order.total.gross.currency,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Always send admin notification for both ORDER_CREATED and ORDER_CONFIRMED
|
|
||||||
await sendEmailToAdmin({
|
|
||||||
subject: `🎉 New Order #${order.number} - ${formatPrice(order.total.gross.amount, currency)}`,
|
|
||||||
react: OrderConfirmation({
|
|
||||||
language: "en",
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerEmail,
|
|
||||||
customerName,
|
|
||||||
items: parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
shippingAddress: formatAddress(order.shippingAddress),
|
|
||||||
billingAddress: formatAddress(order.billingAddress),
|
|
||||||
phone,
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
dashboardUrl: DASHBOARD_URL,
|
|
||||||
isAdmin: true,
|
|
||||||
}),
|
|
||||||
eventType: "ORDER_CONFIRMED",
|
|
||||||
orderId: order.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track order in OpenPanel
|
|
||||||
op.track("order_received", {
|
|
||||||
order_id: order.id,
|
|
||||||
order_number: order.number,
|
|
||||||
total: order.total.gross.amount,
|
|
||||||
currency: order.total.gross.currency,
|
|
||||||
item_count: order.lines.reduce((sum, line) => sum + line.quantity, 0),
|
|
||||||
customer_email: customerEmail,
|
|
||||||
event_type: eventType,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track revenue using OpenPanel's revenue method
|
|
||||||
op.revenue(order.total.gross.amount, {
|
|
||||||
currency: order.total.gross.currency,
|
|
||||||
order_id: order.id,
|
|
||||||
order_number: order.number,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOrderFulfilled(order: SaleorOrder) {
|
async function handleOrderFulfilled(order: Order) {
|
||||||
const language = getCustomerLanguage(order);
|
const { trackingNumber, trackingUrl } = getTrackingInfo(order);
|
||||||
const currency = order.total.gross.currency;
|
|
||||||
const customerName = getCustomerName(order);
|
|
||||||
const customerEmail = order.userEmail;
|
|
||||||
|
|
||||||
let trackingNumber: string | undefined;
|
await orderNotificationService.sendOrderShipped(order, trackingNumber, trackingUrl);
|
||||||
let trackingUrl: string | undefined;
|
await orderNotificationService.sendOrderShippedToAdmin(order, trackingNumber, trackingUrl);
|
||||||
|
|
||||||
if (order.metadata) {
|
|
||||||
const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber");
|
|
||||||
if (trackingMeta) {
|
|
||||||
trackingNumber = trackingMeta.value;
|
|
||||||
}
|
|
||||||
const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl");
|
|
||||||
if (trackingUrlMeta) {
|
|
||||||
trackingUrl = trackingUrlMeta.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendEmailToCustomer({
|
|
||||||
to: customerEmail,
|
|
||||||
subject:
|
|
||||||
language === "sr"
|
|
||||||
? `Vaša narudžbina #${order.number} je poslata!`
|
|
||||||
: language === "de"
|
|
||||||
? `Ihre Bestellung #${order.number} wurde versendet!`
|
|
||||||
: language === "fr"
|
|
||||||
? `Votre commande #${order.number} a été expédiée!`
|
|
||||||
: `Your Order #${order.number} Has Shipped!`,
|
|
||||||
react: OrderShipped({
|
|
||||||
language,
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: parseOrderItems(order.lines, currency),
|
|
||||||
trackingNumber,
|
|
||||||
trackingUrl,
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
language,
|
|
||||||
idempotencyKey: `order-fulfilled/${order.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendEmailToAdmin({
|
|
||||||
subject: `Order Shipped #${order.number} - ${customerName}`,
|
|
||||||
react: OrderShipped({
|
|
||||||
language: "en",
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: parseOrderItems(order.lines, currency),
|
|
||||||
trackingNumber,
|
|
||||||
trackingUrl,
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
eventType: "ORDER_FULFILLED",
|
|
||||||
orderId: order.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOrderCancelled(order: SaleorOrder) {
|
async function handleOrderCancelled(order: Order) {
|
||||||
const language = getCustomerLanguage(order);
|
const reason = getCancellationReason(order);
|
||||||
const currency = order.total.gross.currency;
|
|
||||||
const customerName = getCustomerName(order);
|
|
||||||
const customerEmail = order.userEmail;
|
|
||||||
|
|
||||||
let reason: string | undefined;
|
await orderNotificationService.sendOrderCancelled(order, reason);
|
||||||
if (order.metadata) {
|
await orderNotificationService.sendOrderCancelledToAdmin(order, reason);
|
||||||
const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason");
|
|
||||||
if (reasonMeta) {
|
|
||||||
reason = reasonMeta.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendEmailToCustomer({
|
|
||||||
to: customerEmail,
|
|
||||||
subject:
|
|
||||||
language === "sr"
|
|
||||||
? `Vaša narudžbina #${order.number} je otkazana`
|
|
||||||
: language === "de"
|
|
||||||
? `Ihre Bestellung #${order.number} wurde storniert`
|
|
||||||
: language === "fr"
|
|
||||||
? `Votre commande #${order.number} a été annulée`
|
|
||||||
: `Your Order #${order.number} Has Been Cancelled`,
|
|
||||||
react: OrderCancelled({
|
|
||||||
language,
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
reason,
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
language,
|
|
||||||
idempotencyKey: `order-cancelled/${order.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendEmailToAdmin({
|
|
||||||
subject: `Order Cancelled #${order.number} - ${customerName}`,
|
|
||||||
react: OrderCancelled({
|
|
||||||
language: "en",
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
reason,
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
eventType: "ORDER_CANCELLED",
|
|
||||||
orderId: order.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOrderFullyPaid(order: SaleorOrder) {
|
async function handleOrderFullyPaid(order: Order) {
|
||||||
const language = getCustomerLanguage(order);
|
await orderNotificationService.sendOrderPaid(order);
|
||||||
const currency = order.total.gross.currency;
|
await orderNotificationService.sendOrderPaidToAdmin(order);
|
||||||
const customerName = getCustomerName(order);
|
|
||||||
const customerEmail = order.userEmail;
|
|
||||||
|
|
||||||
await sendEmailToCustomer({
|
|
||||||
to: customerEmail,
|
|
||||||
subject:
|
|
||||||
language === "sr"
|
|
||||||
? `Plaćanje za narudžbinu #${order.number} je primljeno!`
|
|
||||||
: language === "de"
|
|
||||||
? `Zahlung für Bestellung #${order.number} erhalten!`
|
|
||||||
: language === "fr"
|
|
||||||
? `Paiement reçu pour la commande #${order.number}!`
|
|
||||||
: `Payment Received for Order #${order.number}!`,
|
|
||||||
react: OrderPaid({
|
|
||||||
language,
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
language,
|
|
||||||
idempotencyKey: `order-paid/${order.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendEmailToAdmin({
|
|
||||||
subject: `Payment Received #${order.number} - ${customerName} - ${formatPrice(order.total.gross.amount, currency)}`,
|
|
||||||
react: OrderPaid({
|
|
||||||
language: "en",
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
eventType: "ORDER_FULLY_PAID",
|
|
||||||
orderId: order.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaleorWebhook(
|
// Main webhook processor
|
||||||
event: string,
|
async function processWebhook(event: string, order: Order) {
|
||||||
payload: { order: SaleorOrder }
|
console.log(`Processing webhook event: ${event} for order ${order.id}`);
|
||||||
) {
|
|
||||||
const { order } = payload;
|
|
||||||
|
|
||||||
console.log(`Processing webhook event: ${event} for order ${order?.id}`);
|
|
||||||
|
|
||||||
if (!order || !order.id) {
|
|
||||||
console.error("No order in payload");
|
|
||||||
throw new Error("No order in payload");
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case "ORDER_CREATED":
|
case "ORDER_CREATED":
|
||||||
@@ -537,54 +234,47 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
console.log("=== WEBHOOK RECEIVED ===");
|
console.log("=== WEBHOOK RECEIVED ===");
|
||||||
console.log("Timestamp:", new Date().toISOString());
|
console.log("Timestamp:", new Date().toISOString());
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const headers = request.headers;
|
const headers = request.headers;
|
||||||
|
|
||||||
const event = headers.get("saleor-event") as string;
|
const event = headers.get("saleor-event") as string;
|
||||||
const domain = headers.get("saleor-domain");
|
const domain = headers.get("saleor-domain");
|
||||||
const signature = headers.get("saleor-signature");
|
|
||||||
const apiUrl = headers.get("saleor-api-url");
|
|
||||||
|
|
||||||
console.log(`Received webhook: ${event} from ${domain}`);
|
console.log(`Received webhook: ${event} from ${domain}`);
|
||||||
console.log("Headers:", { event, domain, apiUrl, hasSignature: !!signature });
|
|
||||||
console.log("Payload:", JSON.stringify(body).substring(0, 500));
|
// Parse payload
|
||||||
|
|
||||||
// Handle Saleor legacy webhook payload format (array with snake_case fields)
|
|
||||||
let orderPayload: SaleorOrderPayload | null = null;
|
let orderPayload: SaleorOrderPayload | null = null;
|
||||||
if (Array.isArray(body) && body.length > 0) {
|
if (Array.isArray(body) && body.length > 0) {
|
||||||
// Legacy format: array with order objects directly
|
|
||||||
orderPayload = body[0] as SaleorOrderPayload;
|
orderPayload = body[0] as SaleorOrderPayload;
|
||||||
} else if (body.data && Array.isArray(body.data)) {
|
} else if (body.data && Array.isArray(body.data)) {
|
||||||
// Subscription format: { data: [...] }
|
|
||||||
orderPayload = body.data[0] as SaleorOrderPayload;
|
orderPayload = body.data[0] as SaleorOrderPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!orderPayload) {
|
if (!orderPayload) {
|
||||||
console.error("No order found in webhook payload");
|
console.error("No order found in webhook payload");
|
||||||
return NextResponse.json({ error: "No order in payload" }, { status: 400 });
|
return NextResponse.json({ error: "No order in payload" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Order 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);
|
||||||
|
}
|
||||||
79
src/lib/services/AnalyticsService.ts
Normal file
79
src/lib/services/AnalyticsService.ts
Normal 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;
|
||||||
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