Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a63098e3e | ||
|
|
2e6668ff0d | ||
|
|
eb9a798d40 | ||
|
|
ab7dfbe48b | ||
|
|
319f62b923 | ||
|
|
f73f3b8576 | ||
|
|
646d057970 | ||
|
|
15a65758d7 | ||
|
|
b5f8ddbaaa | ||
|
|
6dbaf99b29 | ||
|
|
cdd3f9c77e | ||
|
|
17367024c2 |
@@ -78,6 +78,12 @@ spec:
|
||||
value: "https://dev.manoonoils.com"
|
||||
- name: DASHBOARD_URL
|
||||
value: "https://dashboard.manoonoils.com"
|
||||
- name: NEXT_PUBLIC_OPENPANEL_CLIENT_ID
|
||||
value: "fa61f8ae-0b5d-4187-a9b1-5a04b0025674"
|
||||
- name: OPENPANEL_CLIENT_SECRET
|
||||
value: "91126be0d1e78e657e0427df82733832.c6d30edf6ee673da9650a883604169a13ab8579a0dde70cb39b477f4cf441f90"
|
||||
- name: OPENPANEL_API_URL
|
||||
value: "https://op.nodecrew.me/api"
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
@@ -114,6 +120,12 @@ spec:
|
||||
value: "https://dashboard.manoonoils.com"
|
||||
- name: RESEND_API_KEY
|
||||
value: "re_bewcjHuy_DHtksWVUxguj8vFzKiJZNkFi"
|
||||
- name: NEXT_PUBLIC_OPENPANEL_CLIENT_ID
|
||||
value: "fa61f8ae-0b5d-4187-a9b1-5a04b0025674"
|
||||
- name: OPENPANEL_CLIENT_SECRET
|
||||
value: "91126be0d1e78e657e0427df82733832.c6d30edf6ee673da9650a883604169a13ab8579a0dde70cb39b477f4cf441f90"
|
||||
- name: OPENPANEL_API_URL
|
||||
value: "https://op.nodecrew.me/api"
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
|
||||
Generated
+102
-1
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@apollo/client": "^4.1.6",
|
||||
"@openpanel/nextjs": "^1.4.0",
|
||||
"@react-email/components": "^1.0.10",
|
||||
"@react-email/render": "^2.0.4",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -1340,6 +1341,34 @@
|
||||
"node": ">=12.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@openpanel/nextjs": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@openpanel/nextjs/-/nextjs-1.4.0.tgz",
|
||||
"integrity": "sha512-usMxqdrgAEmBHMZBkJJDX4NeqNOhcP3s0iSuB+TflsUem8R8rsso97jiysoTjae9ZJA4gUe1YDIbh7DyARJhzg==",
|
||||
"dependencies": {
|
||||
"@openpanel/web": "1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@openpanel/sdk": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@openpanel/sdk/-/sdk-1.3.0.tgz",
|
||||
"integrity": "sha512-VK/1oawBjGdxA+oYtqcWlNXlLT1zRJ9tslHoMvqqsqlcLNOhH26ltcHpyGp5RhtIF7uIkCltiicALfFN7fyldw=="
|
||||
},
|
||||
"node_modules/@openpanel/web": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@openpanel/web/-/web-1.3.0.tgz",
|
||||
"integrity": "sha512-geUPcn35oMqWlBS7rB4ejP6qzKGs4VDAZhoSw9MD3q/UYkD/pfTEx70z1ydGVJMjHREdXoAL1XVhBLdZmu1gsw==",
|
||||
"dependencies": {
|
||||
"@openpanel/sdk": "1.3.0",
|
||||
"@rrweb/types": "2.0.0-alpha.20",
|
||||
"rrweb": "2.0.0-alpha.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@parcel/watcher": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||
@@ -1984,6 +2013,18 @@
|
||||
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/@rrweb/types": {
|
||||
"version": "2.0.0-alpha.20",
|
||||
"resolved": "https://registry.npmjs.org/@rrweb/types/-/types-2.0.0-alpha.20.tgz",
|
||||
"integrity": "sha512-RbnDgKxA/odwB1R4gF7eUUj+rdSrq6ROQJsnMw7MIsGzlbSYvJeZN8YY4XqU0G6sKJvXI6bSzk7w/G94jNwzhw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rrweb/utils": {
|
||||
"version": "2.0.0-alpha.20",
|
||||
"resolved": "https://registry.npmjs.org/@rrweb/utils/-/utils-2.0.0-alpha.20.tgz",
|
||||
"integrity": "sha512-MTQOmhPRe39C0fYaCnnVYOufQsyGzwNXpUStKiyFSfGLUJrzuwhbRoUAKR5w6W2j5XuA0bIz3ZDIBztkquOhLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
@@ -2482,6 +2523,12 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/css-font-loading-module": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz",
|
||||
"integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -3145,6 +3192,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@xstate/fsm": {
|
||||
"version": "1.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
|
||||
"integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -3438,6 +3491,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
@@ -6178,6 +6240,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/motion-dom": {
|
||||
"version": "12.34.3",
|
||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
|
||||
@@ -6749,7 +6817,6 @@
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -6996,6 +7063,40 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rrdom": {
|
||||
"version": "2.0.0-alpha.20",
|
||||
"resolved": "https://registry.npmjs.org/rrdom/-/rrdom-2.0.0-alpha.20.tgz",
|
||||
"integrity": "sha512-hoqjS4662LtBp82qEz9GrqU36UpEmCvTA2Hns3qdF7cklLFFy3G+0Th8hLytJENleHHWxsB5nWJ3eXz5mSRxdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"rrweb-snapshot": "^2.0.0-alpha.20"
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb": {
|
||||
"version": "2.0.0-alpha.20",
|
||||
"resolved": "https://registry.npmjs.org/rrweb/-/rrweb-2.0.0-alpha.20.tgz",
|
||||
"integrity": "sha512-CZKDlm+j1VA50Ko3gnMbpvguCAleljsTNXPnVk9aeNP8o6T6kolRbISHyDZpqZ4G+bdDLlQOignPP3jEsXs8Gg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rrweb/types": "^2.0.0-alpha.20",
|
||||
"@rrweb/utils": "^2.0.0-alpha.20",
|
||||
"@types/css-font-loading-module": "0.0.7",
|
||||
"@xstate/fsm": "^1.4.0",
|
||||
"base64-arraybuffer": "^1.0.1",
|
||||
"mitt": "^3.0.0",
|
||||
"rrdom": "^2.0.0-alpha.20",
|
||||
"rrweb-snapshot": "^2.0.0-alpha.20"
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb-snapshot": {
|
||||
"version": "2.0.0-alpha.20",
|
||||
"resolved": "https://registry.npmjs.org/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.20.tgz",
|
||||
"integrity": "sha512-YTNf9YVeaGRo/jxY3FKBge2c/Ojd/KTHmuWloUSB+oyPXuY73ZeeG873qMMmhIpqEn7hn7aBF1eWEQmP7wjf8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"postcss": "^8.4.38"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^4.1.6",
|
||||
"@openpanel/nextjs": "^1.4.0",
|
||||
"@react-email/components": "^1.0.10",
|
||||
"@react-email/render": "^2.0.4",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -10,6 +10,7 @@ import Footer from "@/components/layout/Footer";
|
||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||
import { formatPrice } from "@/lib/saleor";
|
||||
import { saleorClient } from "@/lib/saleor/client";
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import {
|
||||
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||
CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||
@@ -96,6 +97,7 @@ export default function CheckoutPage() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore();
|
||||
const { trackCheckoutStarted, trackCheckoutStep, trackOrderCompleted, identifyUser } = useAnalytics();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [orderComplete, setOrderComplete] = useState(false);
|
||||
@@ -138,6 +140,25 @@ export default function CheckoutPage() {
|
||||
}
|
||||
}, [checkout, refreshCheckout]);
|
||||
|
||||
// Track checkout started when page loads
|
||||
useEffect(() => {
|
||||
if (checkout) {
|
||||
const lines = getLines();
|
||||
const total = getTotal();
|
||||
trackCheckoutStarted({
|
||||
total,
|
||||
currency: "RSD",
|
||||
item_count: lines.reduce((sum, line) => sum + line.quantity, 0),
|
||||
items: lines.map(line => ({
|
||||
id: line.variant.id,
|
||||
name: line.variant.product.name,
|
||||
quantity: line.quantity,
|
||||
price: line.variant.pricing?.price?.gross?.amount || 0,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}, [checkout]);
|
||||
|
||||
// Scroll to top when order is complete
|
||||
useEffect(() => {
|
||||
if (orderComplete) {
|
||||
@@ -257,6 +278,27 @@ export default function CheckoutPage() {
|
||||
if (order) {
|
||||
setOrderNumber(order.number);
|
||||
setOrderComplete(true);
|
||||
|
||||
// Track order completion
|
||||
const lines = getLines();
|
||||
const total = getTotal();
|
||||
trackOrderCompleted({
|
||||
order_id: checkout.id,
|
||||
order_number: order.number,
|
||||
total,
|
||||
currency: "RSD",
|
||||
item_count: lines.reduce((sum, line) => sum + line.quantity, 0),
|
||||
shipping_cost: shippingMethods.find(m => m.id === selectedShippingMethod)?.price.amount,
|
||||
customer_email: shippingAddress.email,
|
||||
});
|
||||
|
||||
// Identify the user
|
||||
identifyUser({
|
||||
profileId: shippingAddress.email,
|
||||
email: shippingAddress.email,
|
||||
firstName: shippingAddress.firstName,
|
||||
lastName: shippingAddress.lastName,
|
||||
});
|
||||
} else {
|
||||
throw new Error(t("errorCreatingOrder"));
|
||||
}
|
||||
@@ -330,6 +372,11 @@ export default function CheckoutPage() {
|
||||
setShippingMethods(availableMethods);
|
||||
setShowShippingMethods(true);
|
||||
|
||||
// Track shipping step
|
||||
trackCheckoutStep("shipping_method_selection", {
|
||||
available_methods_count: availableMethods.length,
|
||||
});
|
||||
|
||||
// Don't complete yet - show shipping method selection
|
||||
console.log("Phase 1 complete. Waiting for shipping method selection...");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Metadata } from "next";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
|
||||
import { OpenPanelComponent } from "@openpanel/nextjs";
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||
|
||||
@@ -44,8 +45,17 @@ export default async function LocaleLayout({
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<>
|
||||
<OpenPanelComponent
|
||||
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""}
|
||||
trackScreenViews={true}
|
||||
trackOutgoingLinks={true}
|
||||
apiUrl="https://op.nodecrew.me/api"
|
||||
scriptUrl="https://op.nodecrew.me/op1.js"
|
||||
/>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRouteHandler } from "@openpanel/nextjs/server";
|
||||
|
||||
export const { GET, POST } = createRouteHandler({
|
||||
apiUrl: "https://op.nodecrew.me/api",
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
// 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<string, string> = {
|
||||
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,322 +131,86 @@ 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,
|
||||
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,
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
itemCount,
|
||||
customerEmail: order.userEmail,
|
||||
eventType,
|
||||
});
|
||||
|
||||
// Track revenue using OpenPanel's revenue method
|
||||
op.revenue(order.total.gross.amount, {
|
||||
analyticsService.trackRevenue({
|
||||
amount: order.total.gross.amount,
|
||||
currency: order.total.gross.currency,
|
||||
order_id: order.id,
|
||||
order_number: order.number,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleOrderFulfilled(order: SaleorOrder) {
|
||||
const language = getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
let trackingNumber: string | undefined;
|
||||
let trackingUrl: string | undefined;
|
||||
|
||||
if (order.metadata) {
|
||||
const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber");
|
||||
if (trackingMeta) {
|
||||
trackingNumber = trackingMeta.value;
|
||||
}
|
||||
const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl");
|
||||
if (trackingUrlMeta) {
|
||||
trackingUrl = trackingUrlMeta.value;
|
||||
}
|
||||
}
|
||||
|
||||
await sendEmailToCustomer({
|
||||
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) {
|
||||
const language = getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
let reason: string | undefined;
|
||||
if (order.metadata) {
|
||||
const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason");
|
||||
if (reasonMeta) {
|
||||
reason = reasonMeta.value;
|
||||
}
|
||||
// Send admin notification for both events
|
||||
await orderNotificationService.sendOrderConfirmationToAdmin(order);
|
||||
}
|
||||
|
||||
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}`,
|
||||
});
|
||||
async function handleOrderFulfilled(order: Order) {
|
||||
const { trackingNumber, trackingUrl } = getTrackingInfo(order);
|
||||
|
||||
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.sendOrderShipped(order, trackingNumber, trackingUrl);
|
||||
await orderNotificationService.sendOrderShippedToAdmin(order, trackingNumber, trackingUrl);
|
||||
}
|
||||
|
||||
async function handleOrderFullyPaid(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);
|
||||
|
||||
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,
|
||||
});
|
||||
await orderNotificationService.sendOrderCancelled(order, reason);
|
||||
await orderNotificationService.sendOrderCancelledToAdmin(order, reason);
|
||||
}
|
||||
|
||||
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");
|
||||
async function handleOrderFullyPaid(order: Order) {
|
||||
await orderNotificationService.sendOrderPaid(order);
|
||||
await orderNotificationService.sendOrderPaidToAdmin(order);
|
||||
}
|
||||
|
||||
// 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":
|
||||
case "ORDER_CONFIRMED":
|
||||
@@ -526,20 +240,14 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -548,26 +256,25 @@ export async function POST(request: NextRequest) {
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function formatPrice(amount: number, currency: string): string {
|
||||
return new Intl.NumberFormat("sr-RS", {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
}).format(amount);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
|
||||
import HowItWorks from "@/components/home/HowItWorks";
|
||||
import NewsletterSection from "@/components/home/NewsletterSection";
|
||||
import BundleSelector from "@/components/product/BundleSelector";
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
|
||||
interface ProductDetailProps {
|
||||
product: Product;
|
||||
@@ -99,8 +100,25 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
|
||||
const [urgencyIndex, setUrgencyIndex] = useState(0);
|
||||
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
|
||||
const { addLine, openCart } = useSaleorCheckoutStore();
|
||||
const { trackProductView, trackAddToCart } = useAnalytics();
|
||||
const validLocale = isValidLocale(locale) ? locale : "sr";
|
||||
|
||||
// Track product view on mount
|
||||
useEffect(() => {
|
||||
const localized = getLocalizedProduct(product, locale);
|
||||
const baseVariant = product.variants?.[0];
|
||||
const price = baseVariant?.pricing?.price?.gross?.amount || 0;
|
||||
const currency = baseVariant?.pricing?.price?.gross?.currency || "RSD";
|
||||
|
||||
trackProductView({
|
||||
id: product.id,
|
||||
name: localized.name,
|
||||
price,
|
||||
currency,
|
||||
category: product.category?.name,
|
||||
});
|
||||
}, [product, locale]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setUrgencyIndex(prev => (prev + 1) % 3);
|
||||
@@ -132,6 +150,25 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
|
||||
setIsAdding(true);
|
||||
try {
|
||||
await addLine(selectedVariantId, 1);
|
||||
|
||||
// Track add to cart
|
||||
const localized = getLocalizedProduct(product, locale);
|
||||
const baseVariant = product.variants?.[0];
|
||||
const selectedVariant = selectedVariantId === baseVariant?.id
|
||||
? baseVariant
|
||||
: bundleProducts.find(p => p.variants?.[0]?.id === selectedVariantId)?.variants?.[0];
|
||||
const price = selectedVariant?.pricing?.price?.gross?.amount || 0;
|
||||
const currency = selectedVariant?.pricing?.price?.gross?.currency || "RSD";
|
||||
|
||||
trackAddToCart({
|
||||
id: product.id,
|
||||
name: localized.name,
|
||||
price,
|
||||
currency,
|
||||
quantity: 1,
|
||||
variant: selectedVariant?.name,
|
||||
});
|
||||
|
||||
openCart();
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useOpenPanel } from "@openpanel/nextjs";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function useAnalytics() {
|
||||
const op = useOpenPanel();
|
||||
|
||||
// Page views are tracked automatically by OpenPanelComponent
|
||||
// but we can track specific events manually
|
||||
|
||||
const trackProductView = useCallback((product: {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
category?: string;
|
||||
}) => {
|
||||
op.track("product_viewed", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
category: product.category,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const trackAddToCart = useCallback((product: {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
quantity: number;
|
||||
variant?: string;
|
||||
}) => {
|
||||
op.track("add_to_cart", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
quantity: product.quantity,
|
||||
variant: product.variant,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const trackRemoveFromCart = useCallback((product: {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
}) => {
|
||||
op.track("remove_from_cart", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
quantity: product.quantity,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const trackCheckoutStarted = useCallback((cart: {
|
||||
total: number;
|
||||
currency: string;
|
||||
item_count: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
}>;
|
||||
}) => {
|
||||
op.track("checkout_started", {
|
||||
cart_total: cart.total,
|
||||
currency: cart.currency,
|
||||
item_count: cart.item_count,
|
||||
items: cart.items,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
|
||||
op.track("checkout_step", {
|
||||
step,
|
||||
...data,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const trackOrderCompleted = useCallback((order: {
|
||||
order_id: string;
|
||||
order_number: string;
|
||||
total: number;
|
||||
currency: string;
|
||||
item_count: number;
|
||||
shipping_cost?: number;
|
||||
customer_email?: string;
|
||||
}) => {
|
||||
op.track("order_completed", {
|
||||
order_id: order.order_id,
|
||||
order_number: order.order_number,
|
||||
total: order.total,
|
||||
currency: order.currency,
|
||||
item_count: order.item_count,
|
||||
shipping_cost: order.shipping_cost,
|
||||
customer_email: order.customer_email,
|
||||
});
|
||||
|
||||
// Also track revenue for analytics
|
||||
op.track("purchase", {
|
||||
transaction_id: order.order_number,
|
||||
value: order.total,
|
||||
currency: order.currency,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const trackSearch = useCallback((query: string, results_count: number) => {
|
||||
op.track("search", {
|
||||
query,
|
||||
results_count,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const trackExternalLink = useCallback((url: string, label?: string) => {
|
||||
op.track("external_link_click", {
|
||||
url,
|
||||
label,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const identifyUser = useCallback((user: {
|
||||
profileId: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}) => {
|
||||
op.identify({
|
||||
profileId: user.profileId,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
properties: user.properties,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
return {
|
||||
trackProductView,
|
||||
trackAddToCart,
|
||||
trackRemoveFromCart,
|
||||
trackCheckoutStarted,
|
||||
trackCheckoutStep,
|
||||
trackOrderCompleted,
|
||||
trackSearch,
|
||||
trackExternalLink,
|
||||
identifyUser,
|
||||
};
|
||||
}
|
||||
|
||||
export default useAnalytics;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { OpenPanel } from "@openpanel/nextjs";
|
||||
|
||||
// Initialize OpenPanel for server-side tracking
|
||||
const op = new OpenPanel({
|
||||
clientId: process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || "",
|
||||
clientSecret: process.env.OPENPANEL_CLIENT_SECRET || "",
|
||||
apiUrl: process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api",
|
||||
});
|
||||
|
||||
export interface OrderAnalyticsData {
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
total: number;
|
||||
currency: string;
|
||||
itemCount: number;
|
||||
customerEmail: string;
|
||||
eventType: string;
|
||||
}
|
||||
|
||||
export interface RevenueData {
|
||||
amount: number;
|
||||
currency: string;
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
}
|
||||
|
||||
class AnalyticsService {
|
||||
private static instance: AnalyticsService;
|
||||
|
||||
static getInstance(): AnalyticsService {
|
||||
if (!AnalyticsService.instance) {
|
||||
AnalyticsService.instance = new AnalyticsService();
|
||||
}
|
||||
return AnalyticsService.instance;
|
||||
}
|
||||
|
||||
async trackOrderReceived(data: OrderAnalyticsData): Promise<void> {
|
||||
try {
|
||||
await op.track("order_received", {
|
||||
order_id: data.orderId,
|
||||
order_number: data.orderNumber,
|
||||
total: data.total,
|
||||
currency: data.currency,
|
||||
item_count: data.itemCount,
|
||||
customer_email: data.customerEmail,
|
||||
event_type: data.eventType,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to track order received:", error);
|
||||
// Don't throw - analytics should not break the main flow
|
||||
}
|
||||
}
|
||||
|
||||
async trackRevenue(data: RevenueData): Promise<void> {
|
||||
try {
|
||||
console.log(`Tracking revenue: ${data.amount} ${data.currency} for order ${data.orderNumber}`);
|
||||
await op.revenue(data.amount, {
|
||||
currency: data.currency,
|
||||
order_id: data.orderId,
|
||||
order_number: data.orderNumber,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to track revenue:", error);
|
||||
// Don't throw - analytics should not break the main flow
|
||||
}
|
||||
}
|
||||
|
||||
async track(eventName: string, properties: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
await op.track(eventName, properties);
|
||||
} catch (error) {
|
||||
console.error(`Failed to track event ${eventName}:`, error);
|
||||
// Don't throw - analytics should not break the main flow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const analyticsService = AnalyticsService.getInstance();
|
||||
export default analyticsService;
|
||||
@@ -0,0 +1,357 @@
|
||||
import { sendEmailToCustomer, sendEmailToAdmin } from "@/lib/resend";
|
||||
import { OrderConfirmation } from "@/emails/OrderConfirmation";
|
||||
import { OrderShipped } from "@/emails/OrderShipped";
|
||||
import { OrderCancelled } from "@/emails/OrderCancelled";
|
||||
import { OrderPaid } from "@/emails/OrderPaid";
|
||||
import { formatPrice } from "@/app/api/webhooks/saleor/utils";
|
||||
|
||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||
const DASHBOARD_URL = process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com";
|
||||
|
||||
// Translation helper for email subjects
|
||||
function getOrderConfirmationSubject(language: string, orderNumber: string): string {
|
||||
const subjects: Record<string, string> = {
|
||||
sr: `Potvrda narudžbine #${orderNumber}`,
|
||||
de: `Bestellbestätigung #${orderNumber}`,
|
||||
fr: `Confirmation de commande #${orderNumber}`,
|
||||
en: `Order Confirmation #${orderNumber}`,
|
||||
};
|
||||
return subjects[language] || subjects.en;
|
||||
}
|
||||
|
||||
function getOrderShippedSubject(language: string, orderNumber: string): string {
|
||||
const subjects: Record<string, string> = {
|
||||
sr: `Vaša narudžbina #${orderNumber} je poslata!`,
|
||||
de: `Ihre Bestellung #${orderNumber} wurde versendet!`,
|
||||
fr: `Votre commande #${orderNumber} a été expédiée!`,
|
||||
en: `Your Order #${orderNumber} Has Shipped!`,
|
||||
};
|
||||
return subjects[language] || subjects.en;
|
||||
}
|
||||
|
||||
function getOrderCancelledSubject(language: string, orderNumber: string): string {
|
||||
const subjects: Record<string, string> = {
|
||||
sr: `Vaša narudžbina #${orderNumber} je otkazana`,
|
||||
de: `Ihre Bestellung #${orderNumber} wurde storniert`,
|
||||
fr: `Votre commande #${orderNumber} a été annulée`,
|
||||
en: `Your Order #${orderNumber} Has Been Cancelled`,
|
||||
};
|
||||
return subjects[language] || subjects.en;
|
||||
}
|
||||
|
||||
function getOrderPaidSubject(language: string, orderNumber: string): string {
|
||||
const subjects: Record<string, string> = {
|
||||
sr: `Plaćanje za narudžbinu #${orderNumber} je primljeno!`,
|
||||
de: `Zahlung für Bestellung #${orderNumber} erhalten!`,
|
||||
fr: `Paiement reçu pour la commande #${orderNumber}!`,
|
||||
en: `Payment Received for Order #${orderNumber}!`,
|
||||
};
|
||||
return subjects[language] || subjects.en;
|
||||
}
|
||||
|
||||
// Interfaces
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
productName: string;
|
||||
variantName?: string;
|
||||
quantity: number;
|
||||
totalPrice: {
|
||||
gross: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface OrderAddress {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
streetAddress1?: string;
|
||||
streetAddress2?: string;
|
||||
city?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
number: string;
|
||||
userEmail: string;
|
||||
user?: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
};
|
||||
billingAddress?: OrderAddress;
|
||||
shippingAddress?: OrderAddress;
|
||||
lines: OrderItem[];
|
||||
total: {
|
||||
gross: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
};
|
||||
languageCode?: string;
|
||||
metadata?: Array<{ key: string; value: string }>;
|
||||
}
|
||||
|
||||
interface OrderEmailItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
}
|
||||
|
||||
class OrderNotificationService {
|
||||
private static instance: OrderNotificationService;
|
||||
|
||||
static getInstance(): OrderNotificationService {
|
||||
if (!OrderNotificationService.instance) {
|
||||
OrderNotificationService.instance = new OrderNotificationService();
|
||||
}
|
||||
return OrderNotificationService.instance;
|
||||
}
|
||||
|
||||
private parseOrderItems(lines: OrderItem[], currency: string): OrderEmailItem[] {
|
||||
return lines.map((line) => ({
|
||||
id: line.id,
|
||||
name: line.variantName ? `${line.productName} (${line.variantName})` : line.productName,
|
||||
quantity: line.quantity,
|
||||
price: formatPrice(line.totalPrice.gross.amount, currency),
|
||||
}));
|
||||
}
|
||||
|
||||
private formatAddress(address?: OrderAddress): string {
|
||||
if (!address) return "";
|
||||
const parts = [
|
||||
address.firstName,
|
||||
address.lastName,
|
||||
address.streetAddress1,
|
||||
address.streetAddress2,
|
||||
address.city,
|
||||
address.postalCode,
|
||||
address.country,
|
||||
].filter(Boolean);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
private getCustomerName(order: Order): string {
|
||||
if (order.user?.firstName || order.user?.lastName) {
|
||||
return `${order.user.firstName || ""} ${order.user.lastName || ""}`.trim();
|
||||
}
|
||||
if (order.shippingAddress?.firstName || order.shippingAddress?.lastName) {
|
||||
return `${order.shippingAddress.firstName || ""} ${order.shippingAddress.lastName || ""}`.trim();
|
||||
}
|
||||
return "Customer";
|
||||
}
|
||||
|
||||
private getCustomerLanguage(order: Order): string {
|
||||
const LANGUAGE_CODE_MAP: Record<string, string> = {
|
||||
SR: "sr",
|
||||
EN: "en",
|
||||
DE: "de",
|
||||
FR: "fr",
|
||||
};
|
||||
|
||||
if (order.languageCode && LANGUAGE_CODE_MAP[order.languageCode]) {
|
||||
return LANGUAGE_CODE_MAP[order.languageCode];
|
||||
}
|
||||
if (order.metadata) {
|
||||
const langMeta = order.metadata.find((m) => m.key === "language");
|
||||
if (langMeta && LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]) {
|
||||
return LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()];
|
||||
}
|
||||
}
|
||||
return "en";
|
||||
}
|
||||
|
||||
async sendOrderConfirmation(order: Order): Promise<void> {
|
||||
const language = this.getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject: getOrderConfirmationSubject(language, order.number),
|
||||
react: OrderConfirmation({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerEmail,
|
||||
customerName,
|
||||
items: this.parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
shippingAddress: this.formatAddress(order.shippingAddress),
|
||||
siteUrl: SITE_URL,
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-confirmed/${order.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
async sendOrderConfirmationToAdmin(order: Order): Promise<void> {
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
|
||||
|
||||
await sendEmailToAdmin({
|
||||
subject: `🎉 New Order #${order.number} - ${formatPrice(order.total.gross.amount, currency)}`,
|
||||
react: OrderConfirmation({
|
||||
language: "en",
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerEmail,
|
||||
customerName,
|
||||
items: this.parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
shippingAddress: this.formatAddress(order.shippingAddress),
|
||||
billingAddress: this.formatAddress(order.billingAddress),
|
||||
phone,
|
||||
siteUrl: SITE_URL,
|
||||
dashboardUrl: DASHBOARD_URL,
|
||||
isAdmin: true,
|
||||
}),
|
||||
eventType: "ORDER_CONFIRMED",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
|
||||
async sendOrderShipped(order: Order, trackingNumber?: string, trackingUrl?: string): Promise<void> {
|
||||
const language = this.getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject: getOrderShippedSubject(language, order.number),
|
||||
react: OrderShipped({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: this.parseOrderItems(order.lines, currency),
|
||||
trackingNumber,
|
||||
trackingUrl,
|
||||
siteUrl: SITE_URL,
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-fulfilled/${order.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
async sendOrderShippedToAdmin(order: Order, trackingNumber?: string, trackingUrl?: string): Promise<void> {
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
|
||||
await sendEmailToAdmin({
|
||||
subject: `Order Shipped #${order.number} - ${customerName}`,
|
||||
react: OrderShipped({
|
||||
language: "en",
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: this.parseOrderItems(order.lines, currency),
|
||||
trackingNumber,
|
||||
trackingUrl,
|
||||
siteUrl: SITE_URL,
|
||||
}),
|
||||
eventType: "ORDER_FULFILLED",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
|
||||
async sendOrderCancelled(order: Order, reason?: string): Promise<void> {
|
||||
const language = this.getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject: getOrderCancelledSubject(language, order.number),
|
||||
react: OrderCancelled({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: this.parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
reason,
|
||||
siteUrl: SITE_URL,
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-cancelled/${order.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
async sendOrderCancelledToAdmin(order: Order, reason?: string): Promise<void> {
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
|
||||
await sendEmailToAdmin({
|
||||
subject: `Order Cancelled #${order.number} - ${customerName}`,
|
||||
react: OrderCancelled({
|
||||
language: "en",
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: this.parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
reason,
|
||||
siteUrl: SITE_URL,
|
||||
}),
|
||||
eventType: "ORDER_CANCELLED",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
|
||||
async sendOrderPaid(order: Order): Promise<void> {
|
||||
const language = this.getCustomerLanguage(order);
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
const customerEmail = order.userEmail;
|
||||
|
||||
await sendEmailToCustomer({
|
||||
to: customerEmail,
|
||||
subject: getOrderPaidSubject(language, order.number),
|
||||
react: OrderPaid({
|
||||
language,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: this.parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
siteUrl: SITE_URL,
|
||||
}),
|
||||
language,
|
||||
idempotencyKey: `order-paid/${order.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
async sendOrderPaidToAdmin(order: Order): Promise<void> {
|
||||
const currency = order.total.gross.currency;
|
||||
const customerName = this.getCustomerName(order);
|
||||
|
||||
await sendEmailToAdmin({
|
||||
subject: `Payment Received #${order.number} - ${customerName} - ${formatPrice(order.total.gross.amount, currency)}`,
|
||||
react: OrderPaid({
|
||||
language: "en",
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
customerName,
|
||||
items: this.parseOrderItems(order.lines, currency),
|
||||
total: formatPrice(order.total.gross.amount, currency),
|
||||
siteUrl: SITE_URL,
|
||||
}),
|
||||
eventType: "ORDER_FULLY_PAID",
|
||||
orderId: order.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const orderNotificationService = OrderNotificationService.getInstance();
|
||||
export default orderNotificationService;
|
||||
Reference in New Issue
Block a user