Compare commits

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

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

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

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

- New utils file: formatPrice() shared between services

This prevents future bugs by:
1. Centralizing email logic in one place
2. Making code testable (services can be unit tested)
3. Easier to add new webhook handlers
4. Translation logic not mixed with HTTP code
5. Analytics failures don't break order processing
2026-03-25 20:22:55 +02:00
Unchained 319f62b923 chore(k8s): sync deployment.yaml with master - add OpenPanel env vars 2026-03-25 20:10:20 +02:00
Unchained f73f3b8576 chore(k8s): sync deployment.yaml with master - add OpenPanel env vars 2026-03-25 20:09:42 +02:00
Unchained 646d057970 Merge branch 'feature/saleor-emails' into dev 2026-03-25 19:58:17 +02:00
Unchained a0fa0f5401 fix(analytics): add OpenPanel revenue tracking to webhooks
Add OpenPanel import and initialization that was missing from webhook route.
Add order_received and revenue tracking when orders are confirmed.
Revenue tracking uses op.revenue() method with amount, currency,
order_id, and order_number properties.
2026-03-25 19:57:43 +02:00
Unchained aa7a0ed3c8 fix(webhook): remove incorrect /100 division from formatPrice
Saleor stores amounts as actual currency values (e.g., 5479 RSD),
not as cents (e.g., 547900). The formatPrice function was incorrectly
dividing by 100, causing prices like 5479 RSD to display as 55 RSD.
2026-03-25 19:53:08 +02:00
Unchained 15a65758d7 fix(webhook): remove incorrect /100 division from formatPrice
Saleor stores amounts as actual currency values (e.g., 5479 RSD),
not as cents (e.g., 547900). The formatPrice function was incorrectly
dividing by 100, causing prices like 5479 RSD to display as 55 RSD.
2026-03-25 19:51:27 +02:00
Unchained b5f8ddbaaa fix(analytics): add OpenPanel client secret and server-side tracking
Build and Deploy / build (push) Has been cancelled
- Add OPENPANEL_CLIENT_SECRET for server-side tracking
- Add OPENPANEL_API_URL environment variable
- Add server-side OpenPanel tracking for orders
- Track order_received and revenue events on webhook,description:Commit OpenPanel fixes
2026-03-25 19:15:48 +02:00
Unchained 6dbaf99b29 fix(analytics): use direct OpenPanel URL instead of proxy
Use https://op.nodecrew.me/api and https://op.nodecrew.me/op1.js
directly since OpenPanel is self-hosted.
2026-03-25 19:09:10 +02:00
Unchained cdd3f9c77e fix(analytics): add proxy route and correct script URL for OpenPanel
- Add /api/op/[...path] proxy route to forward events to self-hosted OpenPanel
- Add scriptUrl=/api/op/op1.js to OpenPanelComponent
- Proxy prevents ad blockers from blocking tracking requests
2026-03-25 19:07:52 +02:00
Unchained 17367024c2 feat(analytics): add OpenPanel tracking to storefront
Add comprehensive OpenPanel analytics tracking:
- Install @openpanel/nextjs SDK
- Add OpenPanelComponent to root layout for automatic page views
- Create useAnalytics hook for tracking custom events
- Track checkout funnel: started, shipping step, order completed
- Track product views and add-to-cart events
- Identify users on order completion
- Add NEXT_PUBLIC_OPENPANEL_CLIENT_ID to environment
2026-03-25 18:48:13 +02:00
Unchained bf628f873f fix(webhook): prevent duplicate customer emails by only sending on ORDER_CONFIRMED
Build and Deploy / build (push) Has been cancelled
Both ORDER_CREATED and ORDER_CONFIRMED were sending customer emails,
causing duplicates. Now only ORDER_CONFIRMED sends customer emails,
while both events still notify admins.
2026-03-25 16:17:35 +02:00
Unchained eb311568db fix(webhook): get currency from channel.currency_code instead of top-level
Build and Deploy / build (push) Has been cancelled
Saleor webhook payload stores currency in channel.currency_code,
not as a top-level currency field. Updated interfaces and conversion
function to use the correct location.
2026-03-25 15:32:49 +02:00
Unchained c9aaacc452 fix(webhook): handle Saleor legacy webhook payload format with snake_case fields
Build and Deploy / build (push) Has been cancelled
Saleor sends webhook payloads as arrays with snake_case fields:
- user_email instead of userEmail
- billing_address instead of billingAddress
- total_gross_amount instead of total.gross.amount
- etc.

Added convertPayloadToOrder() function to transform snake_case payload
to camelCase format expected by our email templates.
2026-03-25 15:22:40 +02:00
Unchained e08e919e83 fix(webhook): handle Saleor subscription payload format (array)
Build and Deploy / build (push) Has been cancelled
Saleor sends webhook payloads as arrays in subscription format.
This fix extracts the order from the array or object format.
2026-03-25 15:10:45 +02:00
Unchained 923f805d47 fix(webhook): normalize event names to uppercase for case-insensitive matching
Build and Deploy / build (push) Has been cancelled
Saleor sends event names in lowercase (order_created) but our code
expects uppercase (ORDER_CREATED). This fix normalizes the event
name before checking supported events.
2026-03-25 14:59:32 +02:00
Unchained 6e0a05c314 fix(k8s): add RESEND_API_KEY and DASHBOARD_URL env vars to deployment
Build and Deploy / build (push) Has been cancelled
2026-03-25 14:26:56 +02:00
12 changed files with 996 additions and 326 deletions
+18
View File
@@ -76,6 +76,14 @@ spec:
value: "https://api.manoonoils.com/graphql/" value: "https://api.manoonoils.com/graphql/"
- name: NEXT_PUBLIC_SITE_URL - name: NEXT_PUBLIC_SITE_URL
value: "https://dev.manoonoils.com" 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: volumeMounts:
- name: workspace - name: workspace
mountPath: /workspace mountPath: /workspace
@@ -108,6 +116,16 @@ spec:
value: "https://api.manoonoils.com/graphql/" value: "https://api.manoonoils.com/graphql/"
- name: NEXT_PUBLIC_SITE_URL - name: NEXT_PUBLIC_SITE_URL
value: "https://dev.manoonoils.com" value: "https://dev.manoonoils.com"
- name: DASHBOARD_URL
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: resources:
limits: limits:
cpu: 500m cpu: 500m
+102 -1
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@apollo/client": "^4.1.6", "@apollo/client": "^4.1.6",
"@openpanel/nextjs": "^1.4.0",
"@react-email/components": "^1.0.10", "@react-email/components": "^1.0.10",
"@react-email/render": "^2.0.4", "@react-email/render": "^2.0.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -1340,6 +1341,34 @@
"node": ">=12.4.0" "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": { "node_modules/@parcel/watcher": {
"version": "2.5.6", "version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", "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" "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": { "node_modules/@rtsao/scc": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -2482,6 +2523,12 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3145,6 +3192,12 @@
"node": ">=8" "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": { "node_modules/acorn": {
"version": "8.16.0", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3438,6 +3491,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.10.0", "version": "2.10.0",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", "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" "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": { "node_modules/motion-dom": {
"version": "12.34.3", "version": "12.34.3",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
@@ -6749,7 +6817,6 @@
"version": "8.5.8", "version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -6996,6 +7063,40 @@
"node": ">=0.10.0" "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": { "node_modules/run-parallel": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+1
View File
@@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^4.1.6", "@apollo/client": "^4.1.6",
"@openpanel/nextjs": "^1.4.0",
"@react-email/components": "^1.0.10", "@react-email/components": "^1.0.10",
"@react-email/render": "^2.0.4", "@react-email/render": "^2.0.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+47
View File
@@ -10,6 +10,7 @@ import Footer from "@/components/layout/Footer";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore"; import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { formatPrice } from "@/lib/saleor"; import { formatPrice } from "@/lib/saleor";
import { saleorClient } from "@/lib/saleor/client"; import { saleorClient } from "@/lib/saleor/client";
import { useAnalytics } from "@/lib/analytics";
import { import {
CHECKOUT_SHIPPING_ADDRESS_UPDATE, CHECKOUT_SHIPPING_ADDRESS_UPDATE,
CHECKOUT_BILLING_ADDRESS_UPDATE, CHECKOUT_BILLING_ADDRESS_UPDATE,
@@ -96,6 +97,7 @@ export default function CheckoutPage() {
const locale = useLocale(); const locale = useLocale();
const router = useRouter(); const router = useRouter();
const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore(); const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore();
const { trackCheckoutStarted, trackCheckoutStep, trackOrderCompleted, identifyUser } = useAnalytics();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [orderComplete, setOrderComplete] = useState(false); const [orderComplete, setOrderComplete] = useState(false);
@@ -138,6 +140,25 @@ export default function CheckoutPage() {
} }
}, [checkout, refreshCheckout]); }, [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 // Scroll to top when order is complete
useEffect(() => { useEffect(() => {
if (orderComplete) { if (orderComplete) {
@@ -257,6 +278,27 @@ export default function CheckoutPage() {
if (order) { if (order) {
setOrderNumber(order.number); setOrderNumber(order.number);
setOrderComplete(true); 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 { } else {
throw new Error(t("errorCreatingOrder")); throw new Error(t("errorCreatingOrder"));
} }
@@ -330,6 +372,11 @@ export default function CheckoutPage() {
setShippingMethods(availableMethods); setShippingMethods(availableMethods);
setShowShippingMethods(true); setShowShippingMethods(true);
// Track shipping step
trackCheckoutStep("shipping_method_selection", {
available_methods_count: availableMethods.length,
});
// Don't complete yet - show shipping method selection // Don't complete yet - show shipping method selection
console.log("Phase 1 complete. Waiting for shipping method selection..."); console.log("Phase 1 complete. Waiting for shipping method selection...");
} }
+10
View File
@@ -2,6 +2,7 @@ import { Metadata } from "next";
import { NextIntlClientProvider } from "next-intl"; import { NextIntlClientProvider } from "next-intl";
import { getMessages, setRequestLocale } from "next-intl/server"; import { getMessages, setRequestLocale } from "next-intl/server";
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales"; 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"; 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(); const messages = await getMessages();
return ( 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}> <NextIntlClientProvider messages={messages}>
{children} {children}
</NextIntlClientProvider> </NextIntlClientProvider>
</>
); );
} }
+5
View File
@@ -0,0 +1,5 @@
import { createRouteHandler } from "@openpanel/nextjs/server";
export const { GET, POST } = createRouteHandler({
apiUrl: "https://op.nodecrew.me/api",
});
+173 -318
View File
@@ -1,36 +1,57 @@
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";
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"; interface SaleorLineItemPayload {
id: string;
interface SaleorWebhookHeaders { product_name: string;
"saleor-event": string; variant_name?: string;
"saleor-domain": string; quantity: number;
"saleor-signature"?: string; total_price_gross_amount: string;
"saleor-api-url": string; currency: string;
} }
interface SaleorLineItem { interface SaleorAddressPayload {
first_name?: string;
last_name?: string;
street_address_1?: string;
street_address_2?: string;
city?: string;
postal_code?: string;
country?: string;
phone?: string;
}
interface SaleorOrderPayload {
id: string;
number: number;
user_email: string;
first_name?: string;
last_name?: string;
billing_address?: SaleorAddressPayload;
shipping_address?: SaleorAddressPayload;
lines: SaleorLineItemPayload[];
total_gross_amount: string;
shipping_price_gross_amount?: string;
channel: { currency_code: string };
currency?: string;
language_code?: string;
metadata?: Record<string, string>;
}
// Internal camelCase interfaces
interface OrderItem {
id: string; id: string;
productName: string; productName: string;
variantName?: string; variantName?: string;
quantity: number; quantity: number;
quantityUnit?: string;
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;
@@ -41,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 }>;
} }
@@ -77,299 +83,138 @@ 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", return {
DE: "de", id: payload.id,
FR: "fr", number: String(payload.number),
}; userEmail: payload.user_email,
user: payload.first_name || payload.last_name ? {
function getCustomerLanguage(order: SaleorOrder): string { firstName: payload.first_name,
if (order.languageCode && LANGUAGE_CODE_MAP[order.languageCode]) { lastName: payload.last_name,
return LANGUAGE_CODE_MAP[order.languageCode]; } : undefined,
} billingAddress: payload.billing_address ? {
if (order.metadata) { firstName: payload.billing_address.first_name,
const langMeta = order.metadata.find((m) => m.key === "language"); lastName: payload.billing_address.last_name,
if (langMeta && LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]) { streetAddress1: payload.billing_address.street_address_1,
return LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]; streetAddress2: payload.billing_address.street_address_2,
} city: payload.billing_address.city,
} postalCode: payload.billing_address.postal_code,
return "en"; country: payload.billing_address.country,
} phone: payload.billing_address.phone,
} : undefined,
function formatPrice(amount: number, currency: string): string { shippingAddress: payload.shipping_address ? {
return new Intl.NumberFormat("sr-RS", { firstName: payload.shipping_address.first_name,
style: "currency", lastName: payload.shipping_address.last_name,
currency: currency, streetAddress1: payload.shipping_address.street_address_1,
}).format(amount / 100); streetAddress2: payload.shipping_address.street_address_2,
} city: payload.shipping_address.city,
postalCode: payload.shipping_address.postal_code,
function formatAddress(address?: SaleorAddress): string { country: payload.shipping_address.country,
if (!address) return ""; phone: payload.shipping_address.phone,
const parts = [ } : undefined,
address.firstName, lines: payload.lines.map((line) => ({
address.lastName,
address.streetAddress1,
address.streetAddress2,
address.postalCode,
address.city,
address.country,
].filter(Boolean);
return parts.join(", ");
}
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, id: line.id,
name: line.variantName ? `${line.productName} (${line.variantName})` : line.productName, productName: line.product_name,
variantName: line.variant_name,
quantity: line.quantity, quantity: line.quantity,
price: formatPrice(line.totalPrice.gross.amount, currency), totalPrice: {
})); gross: {
amount: parseInt(line.total_price_gross_amount),
currency: line.currency || payload.channel.currency_code,
},
},
})),
total: {
gross: {
amount: parseInt(payload.total_gross_amount),
currency: payload.channel.currency_code,
},
},
languageCode: payload.language_code?.toUpperCase(),
metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined,
};
} }
async function handleOrderConfirmed(order: SaleorOrder) { // Extract tracking number from metadata
const language = getCustomerLanguage(order); function getTrackingInfo(order: Order): { trackingNumber?: string; trackingUrl?: string } {
const currency = order.total.gross.currency; if (!order.metadata) return {};
const customerName = getCustomerName(order);
const customerEmail = order.userEmail;
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
await sendEmailToCustomer({
to: customerEmail,
subject:
language === "sr"
? `Potvrda narudžbine #${order.number}`
: language === "de"
? `Bestellbestätigung #${order.number}`
: language === "fr"
? `Confirmation de commande #${order.number}`
: `Order Confirmation #${order.number}`,
react: OrderConfirmation({
language,
orderId: order.id,
orderNumber: order.number,
customerEmail,
customerName,
items: parseOrderItems(order.lines, currency),
total: formatPrice(order.total.gross.amount, currency),
shippingAddress: formatAddress(order.shippingAddress),
siteUrl: SITE_URL,
}),
language,
idempotencyKey: `order-confirmed/${order.id}`,
});
await 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,
});
}
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"); const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber");
if (trackingMeta) {
trackingNumber = trackingMeta.value;
}
const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl"); const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl");
if (trackingUrlMeta) {
trackingUrl = trackingUrlMeta.value; return {
} trackingNumber: trackingMeta?.value,
trackingUrl: trackingUrlMeta?.value,
};
} }
await sendEmailToCustomer({ // Extract cancellation reason from metadata
to: customerEmail, function getCancellationReason(order: Order): string | undefined {
subject: if (!order.metadata) return undefined;
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"); const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason");
if (reasonMeta) { return reasonMeta?.value;
reason = reasonMeta.value;
}
} }
await sendEmailToCustomer({ // Webhook handlers
to: customerEmail, async function handleOrderConfirmed(order: Order, eventType: string) {
subject: const itemCount = order.lines.reduce((sum, line) => sum + line.quantity, 0);
language === "sr"
? `Vaša narudžbina #${order.number} je otkazana` // Send customer email only for ORDER_CONFIRMED (not ORDER_CREATED)
: language === "de" if (eventType === "ORDER_CONFIRMED") {
? `Ihre Bestellung #${order.number} wurde storniert` await orderNotificationService.sendOrderConfirmation(order);
: language === "fr"
? `Votre commande #${order.number} a été annulée` // Track revenue and order analytics only on ORDER_CONFIRMED (not ORDER_CREATED)
: `Your Order #${order.number} Has Been Cancelled`, // This prevents duplicate tracking when both events fire for the same order
react: OrderCancelled({ analyticsService.trackOrderReceived({
language,
orderId: order.id, orderId: order.id,
orderNumber: order.number, orderNumber: order.number,
customerName, total: order.total.gross.amount,
items: parseOrderItems(order.lines, currency), currency: order.total.gross.currency,
total: formatPrice(order.total.gross.amount, currency), itemCount,
reason, customerEmail: order.userEmail,
siteUrl: SITE_URL, eventType,
}),
language,
idempotencyKey: `order-cancelled/${order.id}`,
}); });
await sendEmailToAdmin({ analyticsService.trackRevenue({
subject: `Order Cancelled #${order.number} - ${customerName}`, amount: order.total.gross.amount,
react: OrderCancelled({ currency: order.total.gross.currency,
language: "en",
orderId: order.id, orderId: order.id,
orderNumber: order.number, 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) { // Send admin notification for both events
const language = getCustomerLanguage(order); await orderNotificationService.sendOrderConfirmationToAdmin(order);
const currency = order.total.gross.currency;
const customerName = getCustomerName(order);
const customerEmail = order.userEmail;
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( async function handleOrderFulfilled(order: Order) {
event: string, const { trackingNumber, trackingUrl } = getTrackingInfo(order);
payload: { order: SaleorOrder }
) {
const { order } = payload;
console.log(`Processing webhook event: ${event} for order ${order?.id}`); await orderNotificationService.sendOrderShipped(order, trackingNumber, trackingUrl);
await orderNotificationService.sendOrderShippedToAdmin(order, trackingNumber, trackingUrl);
if (!order || !order.id) {
console.error("No order in payload");
throw new Error("No order in payload");
} }
async function handleOrderCancelled(order: Order) {
const reason = getCancellationReason(order);
await orderNotificationService.sendOrderCancelled(order, reason);
await orderNotificationService.sendOrderCancelledToAdmin(order, reason);
}
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) { switch (event) {
case "ORDER_CREATED": case "ORDER_CREATED":
case "ORDER_CONFIRMED": case "ORDER_CONFIRMED":
await handleOrderConfirmed(order); await handleOrderConfirmed(order, event);
break; break;
case "ORDER_FULFILLED": case "ORDER_FULFILLED":
await handleOrderFulfilled(order); await handleOrderFulfilled(order);
@@ -395,31 +240,41 @@ export async function POST(request: NextRequest) {
const event = headers.get("saleor-event") as string; const event = headers.get("saleor-event") as string;
const domain = headers.get("saleor-domain"); const domain = headers.get("saleor-domain");
const signature = headers.get("saleor-signature");
const apiUrl = headers.get("saleor-api-url");
console.log(`Received webhook: ${event} from ${domain}`); console.log(`Received webhook: ${event} from ${domain}`);
console.log("Headers:", { event, domain, apiUrl, hasSignature: !!signature });
console.log("Payload keys:", Object.keys(body));
if (body.order) { // Parse payload
console.log("Order ID:", body.order.id); let orderPayload: SaleorOrderPayload | null = null;
console.log("Order number:", body.order.number); if (Array.isArray(body) && body.length > 0) {
console.log("User email:", body.order.userEmail); orderPayload = body[0] as SaleorOrderPayload;
} else if (body.data && Array.isArray(body.data)) {
orderPayload = body.data[0] as SaleorOrderPayload;
} }
if (!orderPayload) {
console.error("No order found in webhook payload");
return NextResponse.json({ error: "No order in payload" }, { status: 400 });
}
console.log("Order:", {
id: orderPayload.id,
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 });
} }
if (!SUPPORTED_EVENTS.includes(event)) { const normalizedEvent = event.toUpperCase();
if (!SUPPORTED_EVENTS.includes(normalizedEvent)) {
console.log(`Event ${event} 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" });
} }
const payload = body; const order = convertPayloadToOrder(orderPayload);
await processWebhook(normalizedEvent, order);
await handleSaleorWebhook(event, payload);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
+6
View File
@@ -0,0 +1,6 @@
export function formatPrice(amount: number, currency: string): string {
return new Intl.NumberFormat("sr-RS", {
style: "currency",
currency: currency,
}).format(amount);
}
+37
View File
@@ -20,6 +20,7 @@ import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
import HowItWorks from "@/components/home/HowItWorks"; import HowItWorks from "@/components/home/HowItWorks";
import NewsletterSection from "@/components/home/NewsletterSection"; import NewsletterSection from "@/components/home/NewsletterSection";
import BundleSelector from "@/components/product/BundleSelector"; import BundleSelector from "@/components/product/BundleSelector";
import { useAnalytics } from "@/lib/analytics";
interface ProductDetailProps { interface ProductDetailProps {
product: Product; product: Product;
@@ -99,8 +100,25 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
const [urgencyIndex, setUrgencyIndex] = useState(0); const [urgencyIndex, setUrgencyIndex] = useState(0);
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null); const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
const { addLine, openCart } = useSaleorCheckoutStore(); const { addLine, openCart } = useSaleorCheckoutStore();
const { trackProductView, trackAddToCart } = useAnalytics();
const validLocale = isValidLocale(locale) ? locale : "sr"; 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(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setUrgencyIndex(prev => (prev + 1) % 3); setUrgencyIndex(prev => (prev + 1) % 3);
@@ -132,6 +150,25 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
setIsAdding(true); setIsAdding(true);
try { try {
await addLine(selectedVariantId, 1); 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(); openCart();
} finally { } finally {
setIsAdding(false); setIsAdding(false);
+154
View File
@@ -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;
+79
View File
@@ -0,0 +1,79 @@
import { OpenPanel } from "@openpanel/nextjs";
// Initialize OpenPanel for server-side tracking
const op = new OpenPanel({
clientId: process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || "",
clientSecret: process.env.OPENPANEL_CLIENT_SECRET || "",
apiUrl: process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api",
});
export interface OrderAnalyticsData {
orderId: string;
orderNumber: string;
total: number;
currency: string;
itemCount: number;
customerEmail: string;
eventType: string;
}
export interface RevenueData {
amount: number;
currency: string;
orderId: string;
orderNumber: string;
}
class AnalyticsService {
private static instance: AnalyticsService;
static getInstance(): AnalyticsService {
if (!AnalyticsService.instance) {
AnalyticsService.instance = new AnalyticsService();
}
return AnalyticsService.instance;
}
async trackOrderReceived(data: OrderAnalyticsData): Promise<void> {
try {
await op.track("order_received", {
order_id: data.orderId,
order_number: data.orderNumber,
total: data.total,
currency: data.currency,
item_count: data.itemCount,
customer_email: data.customerEmail,
event_type: data.eventType,
});
} catch (error) {
console.error("Failed to track order received:", error);
// Don't throw - analytics should not break the main flow
}
}
async trackRevenue(data: RevenueData): Promise<void> {
try {
console.log(`Tracking revenue: ${data.amount} ${data.currency} for order ${data.orderNumber}`);
await op.revenue(data.amount, {
currency: data.currency,
order_id: data.orderId,
order_number: data.orderNumber,
});
} catch (error) {
console.error("Failed to track revenue:", error);
// Don't throw - analytics should not break the main flow
}
}
async track(eventName: string, properties: Record<string, unknown>): Promise<void> {
try {
await op.track(eventName, properties);
} catch (error) {
console.error(`Failed to track event ${eventName}:`, error);
// Don't throw - analytics should not break the main flow
}
}
}
export const analyticsService = AnalyticsService.getInstance();
export default analyticsService;
@@ -0,0 +1,357 @@
import { sendEmailToCustomer, sendEmailToAdmin } from "@/lib/resend";
import { OrderConfirmation } from "@/emails/OrderConfirmation";
import { OrderShipped } from "@/emails/OrderShipped";
import { OrderCancelled } from "@/emails/OrderCancelled";
import { OrderPaid } from "@/emails/OrderPaid";
import { formatPrice } from "@/app/api/webhooks/saleor/utils";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
const DASHBOARD_URL = process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com";
// Translation helper for email subjects
function getOrderConfirmationSubject(language: string, orderNumber: string): string {
const subjects: Record<string, string> = {
sr: `Potvrda narudžbine #${orderNumber}`,
de: `Bestellbestätigung #${orderNumber}`,
fr: `Confirmation de commande #${orderNumber}`,
en: `Order Confirmation #${orderNumber}`,
};
return subjects[language] || subjects.en;
}
function getOrderShippedSubject(language: string, orderNumber: string): string {
const subjects: Record<string, string> = {
sr: `Vaša narudžbina #${orderNumber} je poslata!`,
de: `Ihre Bestellung #${orderNumber} wurde versendet!`,
fr: `Votre commande #${orderNumber} a été expédiée!`,
en: `Your Order #${orderNumber} Has Shipped!`,
};
return subjects[language] || subjects.en;
}
function getOrderCancelledSubject(language: string, orderNumber: string): string {
const subjects: Record<string, string> = {
sr: `Vaša narudžbina #${orderNumber} je otkazana`,
de: `Ihre Bestellung #${orderNumber} wurde storniert`,
fr: `Votre commande #${orderNumber} a été annulée`,
en: `Your Order #${orderNumber} Has Been Cancelled`,
};
return subjects[language] || subjects.en;
}
function getOrderPaidSubject(language: string, orderNumber: string): string {
const subjects: Record<string, string> = {
sr: `Plaćanje za narudžbinu #${orderNumber} je primljeno!`,
de: `Zahlung für Bestellung #${orderNumber} erhalten!`,
fr: `Paiement reçu pour la commande #${orderNumber}!`,
en: `Payment Received for Order #${orderNumber}!`,
};
return subjects[language] || subjects.en;
}
// Interfaces
interface OrderItem {
id: string;
productName: string;
variantName?: string;
quantity: number;
totalPrice: {
gross: {
amount: number;
currency: string;
};
};
}
interface OrderAddress {
firstName?: string;
lastName?: string;
streetAddress1?: string;
streetAddress2?: string;
city?: string;
postalCode?: string;
country?: string;
phone?: string;
}
interface Order {
id: string;
number: string;
userEmail: string;
user?: {
firstName?: string;
lastName?: string;
};
billingAddress?: OrderAddress;
shippingAddress?: OrderAddress;
lines: OrderItem[];
total: {
gross: {
amount: number;
currency: string;
};
};
languageCode?: string;
metadata?: Array<{ key: string; value: string }>;
}
interface OrderEmailItem {
id: string;
name: string;
quantity: number;
price: string;
}
class OrderNotificationService {
private static instance: OrderNotificationService;
static getInstance(): OrderNotificationService {
if (!OrderNotificationService.instance) {
OrderNotificationService.instance = new OrderNotificationService();
}
return OrderNotificationService.instance;
}
private parseOrderItems(lines: OrderItem[], currency: string): OrderEmailItem[] {
return lines.map((line) => ({
id: line.id,
name: line.variantName ? `${line.productName} (${line.variantName})` : line.productName,
quantity: line.quantity,
price: formatPrice(line.totalPrice.gross.amount, currency),
}));
}
private formatAddress(address?: OrderAddress): string {
if (!address) return "";
const parts = [
address.firstName,
address.lastName,
address.streetAddress1,
address.streetAddress2,
address.city,
address.postalCode,
address.country,
].filter(Boolean);
return parts.join(", ");
}
private getCustomerName(order: Order): string {
if (order.user?.firstName || order.user?.lastName) {
return `${order.user.firstName || ""} ${order.user.lastName || ""}`.trim();
}
if (order.shippingAddress?.firstName || order.shippingAddress?.lastName) {
return `${order.shippingAddress.firstName || ""} ${order.shippingAddress.lastName || ""}`.trim();
}
return "Customer";
}
private getCustomerLanguage(order: Order): string {
const LANGUAGE_CODE_MAP: Record<string, string> = {
SR: "sr",
EN: "en",
DE: "de",
FR: "fr",
};
if (order.languageCode && LANGUAGE_CODE_MAP[order.languageCode]) {
return LANGUAGE_CODE_MAP[order.languageCode];
}
if (order.metadata) {
const langMeta = order.metadata.find((m) => m.key === "language");
if (langMeta && LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]) {
return LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()];
}
}
return "en";
}
async sendOrderConfirmation(order: Order): Promise<void> {
const language = this.getCustomerLanguage(order);
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
const customerEmail = order.userEmail;
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
await sendEmailToCustomer({
to: customerEmail,
subject: getOrderConfirmationSubject(language, order.number),
react: OrderConfirmation({
language,
orderId: order.id,
orderNumber: order.number,
customerEmail,
customerName,
items: this.parseOrderItems(order.lines, currency),
total: formatPrice(order.total.gross.amount, currency),
shippingAddress: this.formatAddress(order.shippingAddress),
siteUrl: SITE_URL,
}),
language,
idempotencyKey: `order-confirmed/${order.id}`,
});
}
async sendOrderConfirmationToAdmin(order: Order): Promise<void> {
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
const customerEmail = order.userEmail;
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
await sendEmailToAdmin({
subject: `🎉 New Order #${order.number} - ${formatPrice(order.total.gross.amount, currency)}`,
react: OrderConfirmation({
language: "en",
orderId: order.id,
orderNumber: order.number,
customerEmail,
customerName,
items: this.parseOrderItems(order.lines, currency),
total: formatPrice(order.total.gross.amount, currency),
shippingAddress: this.formatAddress(order.shippingAddress),
billingAddress: this.formatAddress(order.billingAddress),
phone,
siteUrl: SITE_URL,
dashboardUrl: DASHBOARD_URL,
isAdmin: true,
}),
eventType: "ORDER_CONFIRMED",
orderId: order.id,
});
}
async sendOrderShipped(order: Order, trackingNumber?: string, trackingUrl?: string): Promise<void> {
const language = this.getCustomerLanguage(order);
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
const customerEmail = order.userEmail;
await sendEmailToCustomer({
to: customerEmail,
subject: getOrderShippedSubject(language, order.number),
react: OrderShipped({
language,
orderId: order.id,
orderNumber: order.number,
customerName,
items: this.parseOrderItems(order.lines, currency),
trackingNumber,
trackingUrl,
siteUrl: SITE_URL,
}),
language,
idempotencyKey: `order-fulfilled/${order.id}`,
});
}
async sendOrderShippedToAdmin(order: Order, trackingNumber?: string, trackingUrl?: string): Promise<void> {
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
await sendEmailToAdmin({
subject: `Order Shipped #${order.number} - ${customerName}`,
react: OrderShipped({
language: "en",
orderId: order.id,
orderNumber: order.number,
customerName,
items: this.parseOrderItems(order.lines, currency),
trackingNumber,
trackingUrl,
siteUrl: SITE_URL,
}),
eventType: "ORDER_FULFILLED",
orderId: order.id,
});
}
async sendOrderCancelled(order: Order, reason?: string): Promise<void> {
const language = this.getCustomerLanguage(order);
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
const customerEmail = order.userEmail;
await sendEmailToCustomer({
to: customerEmail,
subject: getOrderCancelledSubject(language, order.number),
react: OrderCancelled({
language,
orderId: order.id,
orderNumber: order.number,
customerName,
items: this.parseOrderItems(order.lines, currency),
total: formatPrice(order.total.gross.amount, currency),
reason,
siteUrl: SITE_URL,
}),
language,
idempotencyKey: `order-cancelled/${order.id}`,
});
}
async sendOrderCancelledToAdmin(order: Order, reason?: string): Promise<void> {
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
await sendEmailToAdmin({
subject: `Order Cancelled #${order.number} - ${customerName}`,
react: OrderCancelled({
language: "en",
orderId: order.id,
orderNumber: order.number,
customerName,
items: this.parseOrderItems(order.lines, currency),
total: formatPrice(order.total.gross.amount, currency),
reason,
siteUrl: SITE_URL,
}),
eventType: "ORDER_CANCELLED",
orderId: order.id,
});
}
async sendOrderPaid(order: Order): Promise<void> {
const language = this.getCustomerLanguage(order);
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
const customerEmail = order.userEmail;
await sendEmailToCustomer({
to: customerEmail,
subject: getOrderPaidSubject(language, order.number),
react: OrderPaid({
language,
orderId: order.id,
orderNumber: order.number,
customerName,
items: this.parseOrderItems(order.lines, currency),
total: formatPrice(order.total.gross.amount, currency),
siteUrl: SITE_URL,
}),
language,
idempotencyKey: `order-paid/${order.id}`,
});
}
async sendOrderPaidToAdmin(order: Order): Promise<void> {
const currency = order.total.gross.currency;
const customerName = this.getCustomerName(order);
await sendEmailToAdmin({
subject: `Payment Received #${order.number} - ${customerName} - ${formatPrice(order.total.gross.amount, currency)}`,
react: OrderPaid({
language: "en",
orderId: order.id,
orderNumber: order.number,
customerName,
items: this.parseOrderItems(order.lines, currency),
total: formatPrice(order.total.gross.amount, currency),
siteUrl: SITE_URL,
}),
eventType: "ORDER_FULLY_PAID",
orderId: order.id,
});
}
}
export const orderNotificationService = OrderNotificationService.getInstance();
export default orderNotificationService;