Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a63098e3e | ||
|
|
2e6668ff0d | ||
|
|
eb9a798d40 | ||
|
|
ab7dfbe48b | ||
|
|
319f62b923 | ||
|
|
f73f3b8576 | ||
|
|
646d057970 | ||
|
|
a0fa0f5401 | ||
|
|
aa7a0ed3c8 | ||
|
|
15a65758d7 | ||
|
|
b5f8ddbaaa | ||
|
|
6dbaf99b29 | ||
|
|
cdd3f9c77e | ||
|
|
17367024c2 | ||
|
|
bf628f873f | ||
|
|
eb311568db | ||
|
|
c9aaacc452 | ||
|
|
e08e919e83 | ||
|
|
923f805d47 | ||
|
|
6e0a05c314 | ||
|
|
5576946829 | ||
|
|
ef83538d0b |
@@ -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
|
||||||
|
|||||||
Generated
+120
-18
@@ -9,7 +9,9 @@
|
|||||||
"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",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.34.4",
|
"framer-motion": "^12.34.4",
|
||||||
"graphql": "^16.13.1",
|
"graphql": "^16.13.1",
|
||||||
@@ -1339,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",
|
||||||
@@ -1743,23 +1773,6 @@
|
|||||||
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-email/components/node_modules/@react-email/render": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.4.tgz",
|
|
||||||
"integrity": "sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"html-to-text": "^9.0.5",
|
|
||||||
"prettier": "^3.5.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^18.0 || ^19.0 || ^19.0.0-rc",
|
|
||||||
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@react-email/container": {
|
"node_modules/@react-email/container": {
|
||||||
"version": "0.0.16",
|
"version": "0.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.16.tgz",
|
||||||
@@ -1883,6 +1896,23 @@
|
|||||||
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-email/render": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"html-to-text": "^9.0.5",
|
||||||
|
"prettier": "^3.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@react-email/row": {
|
"node_modules/@react-email/row": {
|
||||||
"version": "0.0.13",
|
"version": "0.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.13.tgz",
|
||||||
@@ -1983,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",
|
||||||
@@ -2481,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",
|
||||||
@@ -3144,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",
|
||||||
@@ -3437,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",
|
||||||
@@ -6177,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",
|
||||||
@@ -6748,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",
|
||||||
@@ -6995,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",
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.34.4",
|
"framer-motion": "^12.34.4",
|
||||||
"graphql": "^16.13.1",
|
"graphql": "^16.13.1",
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ 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,
|
||||||
CHECKOUT_COMPLETE,
|
CHECKOUT_COMPLETE,
|
||||||
|
CHECKOUT_EMAIL_UPDATE,
|
||||||
|
CHECKOUT_METADATA_UPDATE,
|
||||||
|
CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||||
} from "@/lib/saleor/mutations/Checkout";
|
} from "@/lib/saleor/mutations/Checkout";
|
||||||
|
import { GET_CHECKOUT_BY_ID } from "@/lib/saleor/queries/Checkout";
|
||||||
import type { Checkout } from "@/types/saleor";
|
import type { Checkout } from "@/types/saleor";
|
||||||
|
|
||||||
interface ShippingAddressUpdateResponse {
|
interface ShippingAddressUpdateResponse {
|
||||||
@@ -38,6 +43,43 @@ interface CheckoutCompleteResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EmailUpdateResponse {
|
||||||
|
checkoutEmailUpdate?: {
|
||||||
|
checkout?: Checkout;
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetadataUpdateResponse {
|
||||||
|
updateMetadata?: {
|
||||||
|
item?: {
|
||||||
|
id: string;
|
||||||
|
metadata?: Array<{ key: string; value: string }>;
|
||||||
|
};
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShippingMethodUpdateResponse {
|
||||||
|
checkoutShippingMethodUpdate?: {
|
||||||
|
checkout?: Checkout;
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CheckoutQueryResponse {
|
||||||
|
checkout?: Checkout;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShippingMethod {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface AddressForm {
|
interface AddressForm {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
@@ -45,7 +87,9 @@ interface AddressForm {
|
|||||||
streetAddress2: string;
|
streetAddress2: string;
|
||||||
city: string;
|
city: string;
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CheckoutPage() {
|
export default function CheckoutPage() {
|
||||||
@@ -53,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);
|
||||||
@@ -66,7 +111,9 @@ export default function CheckoutPage() {
|
|||||||
streetAddress2: "",
|
streetAddress2: "",
|
||||||
city: "",
|
city: "",
|
||||||
postalCode: "",
|
postalCode: "",
|
||||||
|
country: "RS",
|
||||||
phone: "",
|
phone: "",
|
||||||
|
email: "",
|
||||||
});
|
});
|
||||||
const [billingAddress, setBillingAddress] = useState<AddressForm>({
|
const [billingAddress, setBillingAddress] = useState<AddressForm>({
|
||||||
firstName: "",
|
firstName: "",
|
||||||
@@ -75,9 +122,15 @@ export default function CheckoutPage() {
|
|||||||
streetAddress2: "",
|
streetAddress2: "",
|
||||||
city: "",
|
city: "",
|
||||||
postalCode: "",
|
postalCode: "",
|
||||||
|
country: "RS",
|
||||||
phone: "",
|
phone: "",
|
||||||
|
email: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
|
||||||
|
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
|
||||||
|
const [showShippingMethods, setShowShippingMethods] = useState(false);
|
||||||
|
|
||||||
const lines = getLines();
|
const lines = getLines();
|
||||||
const total = getTotal();
|
const total = getTotal();
|
||||||
|
|
||||||
@@ -87,9 +140,35 @@ 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
|
||||||
|
useEffect(() => {
|
||||||
|
if (orderComplete) {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, [orderComplete]);
|
||||||
|
|
||||||
const handleShippingChange = (field: keyof AddressForm, value: string) => {
|
const handleShippingChange = (field: keyof AddressForm, value: string) => {
|
||||||
setShippingAddress((prev) => ({ ...prev, [field]: value }));
|
setShippingAddress((prev) => ({ ...prev, [field]: value }));
|
||||||
if (sameAsShipping) {
|
if (sameAsShipping && field !== "email") {
|
||||||
setBillingAddress((prev) => ({ ...prev, [field]: value }));
|
setBillingAddress((prev) => ({ ...prev, [field]: value }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -98,6 +177,10 @@ export default function CheckoutPage() {
|
|||||||
setBillingAddress((prev) => ({ ...prev, [field]: value }));
|
setBillingAddress((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEmailChange = (value: string) => {
|
||||||
|
setShippingAddress((prev) => ({ ...prev, email: value }));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -106,40 +189,80 @@ export default function CheckoutPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!shippingAddress.email || !shippingAddress.email.includes("@")) {
|
||||||
|
setError(t("errorEmailRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shippingAddress.firstName || !shippingAddress.lastName || !shippingAddress.streetAddress1 || !shippingAddress.city || !shippingAddress.postalCode || !shippingAddress.phone) {
|
||||||
|
setError(t("errorFieldsRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shippingResult = await saleorClient.mutate<ShippingAddressUpdateResponse>({
|
// If we're showing shipping methods and one is selected, complete the order
|
||||||
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
if (showShippingMethods && selectedShippingMethod) {
|
||||||
variables: {
|
console.log("Phase 2: Completing order with shipping method...");
|
||||||
checkoutId: checkout.id,
|
|
||||||
shippingAddress: {
|
|
||||||
...shippingAddress,
|
|
||||||
country: "RS",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
|
|
||||||
throw new Error(shippingResult.data.checkoutShippingAddressUpdate.errors[0].message);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
console.log("Step 1: Updating billing address...");
|
||||||
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
||||||
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||||
variables: {
|
variables: {
|
||||||
checkoutId: checkout.id,
|
checkoutId: checkout.id,
|
||||||
billingAddress: {
|
billingAddress: {
|
||||||
...billingAddress,
|
firstName: billingAddress.firstName,
|
||||||
country: "RS",
|
lastName: billingAddress.lastName,
|
||||||
|
streetAddress1: billingAddress.streetAddress1,
|
||||||
|
streetAddress2: billingAddress.streetAddress2,
|
||||||
|
city: billingAddress.city,
|
||||||
|
postalCode: billingAddress.postalCode,
|
||||||
|
country: billingAddress.country,
|
||||||
|
phone: billingAddress.phone,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
|
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
|
||||||
throw new Error(billingResult.data.checkoutBillingAddressUpdate.errors[0].message);
|
throw new Error(`Billing address update failed: ${billingResult.data.checkoutBillingAddressUpdate.errors[0].message}`);
|
||||||
|
}
|
||||||
|
console.log("Step 1: Billing address updated successfully");
|
||||||
|
|
||||||
|
console.log("Step 2: Setting shipping method...");
|
||||||
|
const shippingMethodResult = await saleorClient.mutate<ShippingMethodUpdateResponse>({
|
||||||
|
mutation: CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||||
|
variables: {
|
||||||
|
checkoutId: checkout.id,
|
||||||
|
shippingMethodId: selectedShippingMethod,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shippingMethodResult.data?.checkoutShippingMethodUpdate?.errors && shippingMethodResult.data.checkoutShippingMethodUpdate.errors.length > 0) {
|
||||||
|
throw new Error(`Shipping method update failed: ${shippingMethodResult.data.checkoutShippingMethodUpdate.errors[0].message}`);
|
||||||
|
}
|
||||||
|
console.log("Step 2: Shipping method set successfully");
|
||||||
|
|
||||||
|
console.log("Step 3: Saving phone number...");
|
||||||
|
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
|
||||||
|
mutation: CHECKOUT_METADATA_UPDATE,
|
||||||
|
variables: {
|
||||||
|
checkoutId: checkout.id,
|
||||||
|
metadata: [
|
||||||
|
{ key: "phone", value: shippingAddress.phone },
|
||||||
|
{ key: "shippingPhone", value: shippingAddress.phone },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (metadataResult.data?.updateMetadata?.errors && metadataResult.data.updateMetadata.errors.length > 0) {
|
||||||
|
console.warn("Failed to save phone metadata:", metadataResult.data.updateMetadata.errors);
|
||||||
|
} else {
|
||||||
|
console.log("Step 3: Phone number saved successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("Step 4: Completing checkout...");
|
||||||
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
|
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
|
||||||
mutation: CHECKOUT_COMPLETE,
|
mutation: CHECKOUT_COMPLETE,
|
||||||
variables: {
|
variables: {
|
||||||
@@ -155,12 +278,120 @@ 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"));
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Phase 1: Update email and address, then fetch shipping methods
|
||||||
|
console.log("Phase 1: Updating email and address...");
|
||||||
|
|
||||||
|
console.log("Step 1: Updating email...");
|
||||||
|
const emailResult = await saleorClient.mutate<EmailUpdateResponse>({
|
||||||
|
mutation: CHECKOUT_EMAIL_UPDATE,
|
||||||
|
variables: {
|
||||||
|
checkoutId: checkout.id,
|
||||||
|
email: shippingAddress.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
|
||||||
|
throw new Error(`Email update failed: ${emailResult.data.checkoutEmailUpdate.errors[0].message}`);
|
||||||
|
}
|
||||||
|
console.log("Step 1: Email updated successfully");
|
||||||
|
|
||||||
|
console.log("Step 2: Updating shipping address...");
|
||||||
|
console.log("Shipping address data:", {
|
||||||
|
firstName: shippingAddress.firstName,
|
||||||
|
lastName: shippingAddress.lastName,
|
||||||
|
streetAddress1: shippingAddress.streetAddress1,
|
||||||
|
city: shippingAddress.city,
|
||||||
|
postalCode: shippingAddress.postalCode,
|
||||||
|
country: shippingAddress.country,
|
||||||
|
phone: shippingAddress.phone,
|
||||||
|
});
|
||||||
|
const shippingResult = await saleorClient.mutate<ShippingAddressUpdateResponse>({
|
||||||
|
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||||
|
variables: {
|
||||||
|
checkoutId: checkout.id,
|
||||||
|
shippingAddress: {
|
||||||
|
firstName: shippingAddress.firstName,
|
||||||
|
lastName: shippingAddress.lastName,
|
||||||
|
streetAddress1: shippingAddress.streetAddress1,
|
||||||
|
streetAddress2: shippingAddress.streetAddress2,
|
||||||
|
city: shippingAddress.city,
|
||||||
|
postalCode: shippingAddress.postalCode,
|
||||||
|
country: shippingAddress.country,
|
||||||
|
phone: shippingAddress.phone,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
|
||||||
|
throw new Error(`Shipping address update failed: ${shippingResult.data.checkoutShippingAddressUpdate.errors[0].message}`);
|
||||||
|
}
|
||||||
|
console.log("Step 2: Shipping address updated successfully");
|
||||||
|
|
||||||
|
// Query for checkout to get available shipping methods
|
||||||
|
console.log("Step 3: Fetching shipping methods...");
|
||||||
|
const checkoutQueryResult = await saleorClient.query<CheckoutQueryResponse>({
|
||||||
|
query: GET_CHECKOUT_BY_ID,
|
||||||
|
variables: {
|
||||||
|
id: checkout.id,
|
||||||
|
},
|
||||||
|
fetchPolicy: "network-only",
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableMethods = checkoutQueryResult.data?.checkout?.shippingMethods || [];
|
||||||
|
console.log("Available shipping methods:", availableMethods);
|
||||||
|
|
||||||
|
if (availableMethods.length === 0) {
|
||||||
|
throw new Error(t("errorNoShippingMethods"));
|
||||||
|
}
|
||||||
|
|
||||||
|
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...");
|
||||||
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMessage = err instanceof Error ? err.message : null;
|
console.error("Checkout error:", err);
|
||||||
setError(errorMessage || t("errorOccurred"));
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.name === "AbortError") {
|
||||||
|
setError("Request timed out. Please check your connection and try again.");
|
||||||
|
} else {
|
||||||
|
setError(err.message || t("errorOccurred"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(t("errorOccurred"));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -227,6 +458,36 @@ export default function CheckoutPage() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||||
<div>
|
<div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="border-b border-border pb-6">
|
||||||
|
<h2 className="text-xl font-serif mb-4">{t("contactInfo")}</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("email")}</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={shippingAddress.email}
|
||||||
|
onChange={(e) => handleEmailChange(e.target.value)}
|
||||||
|
className="w-full border border-border px-4 py-2 rounded"
|
||||||
|
placeholder="[email protected]"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-foreground-muted mt-1">{t("emailRequired")}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("phone")}</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
value={shippingAddress.phone}
|
||||||
|
onChange={(e) => handleShippingChange("phone", e.target.value)}
|
||||||
|
className="w-full border border-border px-4 py-2 rounded"
|
||||||
|
placeholder="+381..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-foreground-muted mt-1">{t("phoneRequired")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-b border-border pb-6">
|
<div className="border-b border-border pb-6">
|
||||||
<h2 className="text-xl font-serif mb-4">{t("shippingAddress")}</h2>
|
<h2 className="text-xl font-serif mb-4">{t("shippingAddress")}</h2>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -250,6 +511,35 @@ export default function CheckoutPage() {
|
|||||||
className="w-full border border-border px-4 py-2 rounded"
|
className="w-full border border-border px-4 py-2 rounded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("country")}</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={shippingAddress.country}
|
||||||
|
onChange={(e) => handleShippingChange("country", e.target.value)}
|
||||||
|
className="w-full border border-border px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
<option value="RS">Serbia (Srbija)</option>
|
||||||
|
<option value="BA">Bosnia and Herzegovina</option>
|
||||||
|
<option value="ME">Montenegro</option>
|
||||||
|
<option value="HR">Croatia</option>
|
||||||
|
<option value="SI">Slovenia</option>
|
||||||
|
<option value="MK">North Macedonia</option>
|
||||||
|
<option value="AL">Albania</option>
|
||||||
|
<option value="XK">Kosovo</option>
|
||||||
|
<option value="BG">Bulgaria</option>
|
||||||
|
<option value="RO">Romania</option>
|
||||||
|
<option value="HU">Hungary</option>
|
||||||
|
<option value="DE">Germany</option>
|
||||||
|
<option value="AT">Austria</option>
|
||||||
|
<option value="CH">Switzerland</option>
|
||||||
|
<option value="FR">France</option>
|
||||||
|
<option value="GB">United Kingdom</option>
|
||||||
|
<option value="US">United States</option>
|
||||||
|
<option value="CA">Canada</option>
|
||||||
|
<option value="AU">Australia</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="block text-sm font-medium mb-1">{t("streetAddress")}</label>
|
<label className="block text-sm font-medium mb-1">{t("streetAddress")}</label>
|
||||||
<input
|
<input
|
||||||
@@ -289,16 +579,6 @@ export default function CheckoutPage() {
|
|||||||
className="w-full border border-border px-4 py-2 rounded"
|
className="w-full border border-border px-4 py-2 rounded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium mb-1">{t("phone")}</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
required
|
|
||||||
value={shippingAddress.phone}
|
|
||||||
onChange={(e) => handleShippingChange("phone", e.target.value)}
|
|
||||||
className="w-full border border-border px-4 py-2 rounded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -314,12 +594,49 @@ export default function CheckoutPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Shipping Method Selection */}
|
||||||
|
{showShippingMethods && shippingMethods.length > 0 && (
|
||||||
|
<div className="border-b border-border pb-6">
|
||||||
|
<h2 className="text-xl font-serif mb-4">{t("shippingMethod")}</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{shippingMethods.map((method) => (
|
||||||
|
<label
|
||||||
|
key={method.id}
|
||||||
|
className={`flex items-center justify-between p-4 border rounded cursor-pointer transition-colors ${
|
||||||
|
selectedShippingMethod === method.id
|
||||||
|
? "border-foreground bg-background-ice"
|
||||||
|
: "border-border hover:border-foreground/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="shippingMethod"
|
||||||
|
value={method.id}
|
||||||
|
checked={selectedShippingMethod === method.id}
|
||||||
|
onChange={(e) => setSelectedShippingMethod(e.target.value)}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{method.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-foreground-muted">
|
||||||
|
{formatPrice(method.price.amount)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!selectedShippingMethod && (
|
||||||
|
<p className="text-red-500 text-sm mt-2">{t("errorSelectShipping")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || lines.length === 0}
|
disabled={isLoading || lines.length === 0 || (showShippingMethods && !selectedShippingMethod)}
|
||||||
className="w-full py-4 bg-foreground text-white font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
|
className="w-full py-4 bg-foreground text-white font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isLoading ? t("processing") : t("completeOrder", { total: formatPrice(total) })}
|
{isLoading ? t("processing") : showShippingMethods ? t("completeOrder", { total: formatPrice(total) }) : t("continueToShipping")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { createRouteHandler } from "@openpanel/nextjs/server";
|
||||||
|
|
||||||
|
export const { GET, POST } = createRouteHandler({
|
||||||
|
apiUrl: "https://op.nodecrew.me/api",
|
||||||
|
});
|
||||||
@@ -1,35 +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)
|
||||||
|
interface SaleorLineItemPayload {
|
||||||
interface SaleorWebhookHeaders {
|
id: string;
|
||||||
"saleor-event": string;
|
product_name: string;
|
||||||
"saleor-domain": string;
|
variant_name?: string;
|
||||||
"saleor-signature"?: string;
|
quantity: number;
|
||||||
"saleor-api-url": string;
|
total_price_gross_amount: 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;
|
||||||
@@ -40,328 +62,159 @@ 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 }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SUPPORTED_EVENTS = [
|
const SUPPORTED_EVENTS = [
|
||||||
|
"ORDER_CREATED",
|
||||||
"ORDER_CONFIRMED",
|
"ORDER_CONFIRMED",
|
||||||
"ORDER_FULLY_PAID",
|
"ORDER_FULLY_PAID",
|
||||||
"ORDER_CANCELLED",
|
"ORDER_CANCELLED",
|
||||||
"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;
|
|
||||||
|
|
||||||
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} - ${customerName}`,
|
|
||||||
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),
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
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_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);
|
||||||
@@ -379,28 +232,49 @@ async function handleSaleorWebhook(
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
console.log("=== WEBHOOK RECEIVED ===");
|
||||||
|
console.log("Timestamp:", new Date().toISOString());
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const headers = request.headers;
|
const headers = request.headers;
|
||||||
|
|
||||||
const event = headers.get("saleor-event") as string;
|
const event = headers.get("saleor-event") as string;
|
||||||
const domain = headers.get("saleor-domain");
|
const domain = headers.get("saleor-domain");
|
||||||
const signature = headers.get("saleor-signature");
|
|
||||||
const apiUrl = headers.get("saleor-api-url");
|
|
||||||
|
|
||||||
console.log(`Received webhook: ${event} from ${domain}`);
|
console.log(`Received webhook: ${event} from ${domain}`);
|
||||||
|
|
||||||
|
// Parse payload
|
||||||
|
let orderPayload: SaleorOrderPayload | null = null;
|
||||||
|
if (Array.isArray(body) && body.length > 0) {
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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 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);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function BaseLayout({ children, previewText, language, siteUrl }: BaseLay
|
|||||||
<Container style={styles.container}>
|
<Container style={styles.container}>
|
||||||
<Section style={styles.logoSection}>
|
<Section style={styles.logoSection}>
|
||||||
<Img
|
<Img
|
||||||
src={`${siteUrl}/logo.png`}
|
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
||||||
width="150"
|
width="150"
|
||||||
height="auto"
|
height="auto"
|
||||||
alt="ManoonOils"
|
alt="ManoonOils"
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ interface OrderConfirmationProps {
|
|||||||
items: OrderItem[];
|
items: OrderItem[];
|
||||||
total: string;
|
total: string;
|
||||||
shippingAddress?: string;
|
shippingAddress?: string;
|
||||||
|
billingAddress?: string;
|
||||||
|
phone?: string;
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
|
dashboardUrl?: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const translations: Record<
|
const translations: Record<
|
||||||
@@ -34,6 +38,15 @@ const translations: Record<
|
|||||||
shippingTo: string;
|
shippingTo: string;
|
||||||
questions: string;
|
questions: string;
|
||||||
thankYou: string;
|
thankYou: string;
|
||||||
|
adminTitle: string;
|
||||||
|
adminPreview: string;
|
||||||
|
adminGreeting: string;
|
||||||
|
adminMessage: string;
|
||||||
|
customerLabel: string;
|
||||||
|
customerEmailLabel: string;
|
||||||
|
billingAddressLabel: string;
|
||||||
|
phoneLabel: string;
|
||||||
|
viewDashboard: string;
|
||||||
}
|
}
|
||||||
> = {
|
> = {
|
||||||
sr: {
|
sr: {
|
||||||
@@ -48,6 +61,15 @@ const translations: Record<
|
|||||||
shippingTo: "Adresa za dostavu",
|
shippingTo: "Adresa za dostavu",
|
||||||
questions: "Imate pitanja? Pišite nam na [email protected]",
|
questions: "Imate pitanja? Pišite nam na [email protected]",
|
||||||
thankYou: "Hvala Vam što kupujete kod nas!",
|
thankYou: "Hvala Vam što kupujete kod nas!",
|
||||||
|
adminTitle: "Nova narudžbina!",
|
||||||
|
adminPreview: "Nova narudžbina je primljena",
|
||||||
|
adminGreeting: "Čestitamo na prodaji!",
|
||||||
|
adminMessage: "Nova narudžbina je upravo primljena. Detalji su ispod:",
|
||||||
|
customerLabel: "Kupac",
|
||||||
|
customerEmailLabel: "Email kupca",
|
||||||
|
billingAddressLabel: "Adresa za naplatu",
|
||||||
|
phoneLabel: "Telefon",
|
||||||
|
viewDashboard: "Pogledaj u Dashboardu",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
title: "Order Confirmation",
|
title: "Order Confirmation",
|
||||||
@@ -62,6 +84,15 @@ const translations: Record<
|
|||||||
shippingTo: "Shipping address",
|
shippingTo: "Shipping address",
|
||||||
questions: "Questions? Email us at [email protected]",
|
questions: "Questions? Email us at [email protected]",
|
||||||
thankYou: "Thank you for shopping with us!",
|
thankYou: "Thank you for shopping with us!",
|
||||||
|
adminTitle: "New Order! 🎉",
|
||||||
|
adminPreview: "A new order has been received",
|
||||||
|
adminGreeting: "Congratulations on the sale!",
|
||||||
|
adminMessage: "A new order has just been placed. Details below:",
|
||||||
|
customerLabel: "Customer",
|
||||||
|
customerEmailLabel: "Customer Email",
|
||||||
|
billingAddressLabel: "Billing Address",
|
||||||
|
phoneLabel: "Phone",
|
||||||
|
viewDashboard: "View in Dashboard",
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
title: "Bestellungsbestätigung",
|
title: "Bestellungsbestätigung",
|
||||||
@@ -76,6 +107,15 @@ const translations: Record<
|
|||||||
shippingTo: "Lieferadresse",
|
shippingTo: "Lieferadresse",
|
||||||
questions: "Fragen? Schreiben Sie uns an [email protected]",
|
questions: "Fragen? Schreiben Sie uns an [email protected]",
|
||||||
thankYou: "Vielen Dank für Ihren Einkauf!",
|
thankYou: "Vielen Dank für Ihren Einkauf!",
|
||||||
|
adminTitle: "Neue Bestellung! 🎉",
|
||||||
|
adminPreview: "Eine neue Bestellung wurde erhalten",
|
||||||
|
adminGreeting: "Glückwunsch zum Verkauf!",
|
||||||
|
adminMessage: "Eine neue Bestellung wurde soeben aufgegeben. Details unten:",
|
||||||
|
customerLabel: "Kunde",
|
||||||
|
customerEmailLabel: "Kunden-E-Mail",
|
||||||
|
billingAddressLabel: "Rechnungsadresse",
|
||||||
|
phoneLabel: "Telefon",
|
||||||
|
viewDashboard: "Im Dashboard anzeigen",
|
||||||
},
|
},
|
||||||
fr: {
|
fr: {
|
||||||
title: "Confirmation de commande",
|
title: "Confirmation de commande",
|
||||||
@@ -90,6 +130,15 @@ const translations: Record<
|
|||||||
shippingTo: "Adresse de livraison",
|
shippingTo: "Adresse de livraison",
|
||||||
questions: "Questions? Écrivez-nous à [email protected]",
|
questions: "Questions? Écrivez-nous à [email protected]",
|
||||||
thankYou: "Merci d'avoir Magasiné avec nous!",
|
thankYou: "Merci d'avoir Magasiné avec nous!",
|
||||||
|
adminTitle: "Nouvelle commande! 🎉",
|
||||||
|
adminPreview: "Une nouvelle commande a été reçue",
|
||||||
|
adminGreeting: "Félicitations pour la vente!",
|
||||||
|
adminMessage: "Une nouvelle commande vient d'être passée. Détails ci-dessous:",
|
||||||
|
customerLabel: "Client",
|
||||||
|
customerEmailLabel: "Email du client",
|
||||||
|
billingAddressLabel: "Adresse de facturation",
|
||||||
|
phoneLabel: "Téléphone",
|
||||||
|
viewDashboard: "Voir dans le Dashboard",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -102,10 +151,82 @@ export function OrderConfirmation({
|
|||||||
items,
|
items,
|
||||||
total,
|
total,
|
||||||
shippingAddress,
|
shippingAddress,
|
||||||
|
billingAddress,
|
||||||
|
phone,
|
||||||
siteUrl,
|
siteUrl,
|
||||||
|
dashboardUrl,
|
||||||
|
isAdmin = false,
|
||||||
}: OrderConfirmationProps) {
|
}: OrderConfirmationProps) {
|
||||||
const t = translations[language] || translations.en;
|
const t = translations[language] || translations.en;
|
||||||
|
|
||||||
|
// For admin emails, always use English
|
||||||
|
const adminT = translations["en"];
|
||||||
|
|
||||||
|
if (isAdmin) {
|
||||||
|
return (
|
||||||
|
<BaseLayout previewText={adminT.adminPreview} language="en" siteUrl={siteUrl}>
|
||||||
|
<Text style={styles.title}>{adminT.adminTitle}</Text>
|
||||||
|
<Text style={styles.greeting}>{adminT.adminGreeting}</Text>
|
||||||
|
<Text style={styles.text}>{adminT.adminMessage}</Text>
|
||||||
|
|
||||||
|
<Section style={styles.orderInfo}>
|
||||||
|
<Text style={styles.orderNumber}>
|
||||||
|
<strong>{adminT.orderNumber}:</strong> {orderNumber}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.customerInfo}>
|
||||||
|
<strong>{adminT.customerLabel}:</strong> {customerName}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.customerInfo}>
|
||||||
|
<strong>{adminT.customerEmailLabel}:</strong> {customerEmail}
|
||||||
|
</Text>
|
||||||
|
{phone && (
|
||||||
|
<Text style={styles.customerInfo}>
|
||||||
|
<strong>{adminT.phoneLabel}:</strong> {phone}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={styles.itemsSection}>
|
||||||
|
<Text style={styles.sectionTitle}>{adminT.items}</Text>
|
||||||
|
<Hr style={styles.hr} />
|
||||||
|
{items.map((item) => (
|
||||||
|
<Section key={item.id} style={styles.itemRow}>
|
||||||
|
<Text style={styles.itemName}>
|
||||||
|
{item.quantity}x {item.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||||
|
</Section>
|
||||||
|
))}
|
||||||
|
<Hr style={styles.hr} />
|
||||||
|
<Section style={styles.totalRow}>
|
||||||
|
<Text style={styles.totalLabel}>{adminT.total}:</Text>
|
||||||
|
<Text style={styles.totalValue}>{total}</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{shippingAddress && (
|
||||||
|
<Section style={styles.shippingSection}>
|
||||||
|
<Text style={styles.sectionTitle}>{adminT.shippingTo}</Text>
|
||||||
|
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{billingAddress && (
|
||||||
|
<Section style={styles.shippingSection}>
|
||||||
|
<Text style={styles.sectionTitle}>{adminT.billingAddressLabel}</Text>
|
||||||
|
<Text style={styles.shippingAddress}>{billingAddress}</Text>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section style={styles.buttonSection}>
|
||||||
|
<Button href={`${dashboardUrl}/orders/${orderId}`} style={styles.button}>
|
||||||
|
{adminT.viewDashboard}
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
</BaseLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||||
<Text style={styles.title}>{t.title}</Text>
|
<Text style={styles.title}>{t.title}</Text>
|
||||||
@@ -187,7 +308,12 @@ const styles = {
|
|||||||
orderNumber: {
|
orderNumber: {
|
||||||
fontSize: "14px",
|
fontSize: "14px",
|
||||||
color: "#333333",
|
color: "#333333",
|
||||||
margin: "0",
|
margin: "0 0 8px 0",
|
||||||
|
},
|
||||||
|
customerInfo: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#333333",
|
||||||
|
margin: "0 0 4px 0",
|
||||||
},
|
},
|
||||||
itemsSection: {
|
itemsSection: {
|
||||||
marginBottom: "20px",
|
marginBottom: "20px",
|
||||||
|
|||||||
@@ -340,7 +340,13 @@
|
|||||||
},
|
},
|
||||||
"Checkout": {
|
"Checkout": {
|
||||||
"checkout": "Kasse",
|
"checkout": "Kasse",
|
||||||
|
"contactInfo": "Kontaktinformationen",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"emailRequired": "Erforderlich für Bestellbestätigung",
|
||||||
|
"phoneRequired": "Erforderlich für Lieferkoordination",
|
||||||
"shippingAddress": "Lieferadresse",
|
"shippingAddress": "Lieferadresse",
|
||||||
|
"shippingMethod": "Versandart",
|
||||||
|
"country": "Land",
|
||||||
"firstName": "Vorname",
|
"firstName": "Vorname",
|
||||||
"lastName": "Nachname",
|
"lastName": "Nachname",
|
||||||
"streetAddress": "Straße und Nummer",
|
"streetAddress": "Straße und Nummer",
|
||||||
@@ -364,6 +370,8 @@
|
|||||||
"yourCartEmpty": "Ihr Warenkorb ist leer",
|
"yourCartEmpty": "Ihr Warenkorb ist leer",
|
||||||
"continueShopping": "Weiter einkaufen",
|
"continueShopping": "Weiter einkaufen",
|
||||||
"errorNoCheckout": "Keine aktive Kasse. Bitte versuchen Sie es erneut.",
|
"errorNoCheckout": "Keine aktive Kasse. Bitte versuchen Sie es erneut.",
|
||||||
|
"errorEmailRequired": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
|
||||||
|
"errorFieldsRequired": "Bitte füllen Sie alle erforderlichen Felder aus.",
|
||||||
"errorOccurred": "Ein Fehler ist during des Checkouts aufgetreten.",
|
"errorOccurred": "Ein Fehler ist during des Checkouts aufgetreten.",
|
||||||
"errorCreatingOrder": "Bestellung konnte nicht erstellt werden.",
|
"errorCreatingOrder": "Bestellung konnte nicht erstellt werden.",
|
||||||
"orderConfirmed": "Bestellung bestätigt!",
|
"orderConfirmed": "Bestellung bestätigt!",
|
||||||
|
|||||||
@@ -386,7 +386,13 @@
|
|||||||
},
|
},
|
||||||
"Checkout": {
|
"Checkout": {
|
||||||
"checkout": "Checkout",
|
"checkout": "Checkout",
|
||||||
|
"contactInfo": "Contact Information",
|
||||||
|
"email": "Email",
|
||||||
|
"emailRequired": "Required for order confirmation",
|
||||||
|
"phoneRequired": "Required for delivery coordination",
|
||||||
"shippingAddress": "Shipping Address",
|
"shippingAddress": "Shipping Address",
|
||||||
|
"shippingMethod": "Shipping Method",
|
||||||
|
"country": "Country",
|
||||||
"firstName": "First Name",
|
"firstName": "First Name",
|
||||||
"lastName": "Last Name",
|
"lastName": "Last Name",
|
||||||
"streetAddress": "Street Address",
|
"streetAddress": "Street Address",
|
||||||
@@ -410,8 +416,13 @@
|
|||||||
"yourCartEmpty": "Your cart is empty",
|
"yourCartEmpty": "Your cart is empty",
|
||||||
"continueShopping": "Continue Shopping",
|
"continueShopping": "Continue Shopping",
|
||||||
"errorNoCheckout": "No active checkout. Please try again.",
|
"errorNoCheckout": "No active checkout. Please try again.",
|
||||||
|
"errorEmailRequired": "Please enter a valid email address.",
|
||||||
|
"errorFieldsRequired": "Please fill in all required fields.",
|
||||||
|
"errorNoShippingMethods": "No shipping methods available for this address. Please check your address or contact support.",
|
||||||
|
"errorSelectShipping": "Please select a shipping method.",
|
||||||
"errorOccurred": "An error occurred during checkout.",
|
"errorOccurred": "An error occurred during checkout.",
|
||||||
"errorCreatingOrder": "Failed to create order.",
|
"errorCreatingOrder": "Failed to create order.",
|
||||||
|
"continueToShipping": "Continue to Shipping",
|
||||||
"orderConfirmed": "Order Confirmed!",
|
"orderConfirmed": "Order Confirmed!",
|
||||||
"thankYou": "Thank you for your purchase.",
|
"thankYou": "Thank you for your purchase.",
|
||||||
"orderNumber": "Order Number",
|
"orderNumber": "Order Number",
|
||||||
|
|||||||
@@ -340,7 +340,13 @@
|
|||||||
},
|
},
|
||||||
"Checkout": {
|
"Checkout": {
|
||||||
"checkout": "Commande",
|
"checkout": "Commande",
|
||||||
|
"contactInfo": "Coordonnées",
|
||||||
|
"email": "E-mail",
|
||||||
|
"emailRequired": "Requis pour la confirmation de commande",
|
||||||
|
"phoneRequired": "Requis pour la coordination de livraison",
|
||||||
"shippingAddress": "Adresse de Livraison",
|
"shippingAddress": "Adresse de Livraison",
|
||||||
|
"shippingMethod": "Méthode de livraison",
|
||||||
|
"country": "Pays",
|
||||||
"firstName": "Prénom",
|
"firstName": "Prénom",
|
||||||
"lastName": "Nom",
|
"lastName": "Nom",
|
||||||
"streetAddress": "Rue et Numéro",
|
"streetAddress": "Rue et Numéro",
|
||||||
@@ -364,6 +370,8 @@
|
|||||||
"yourCartEmpty": "Votre panier est vide",
|
"yourCartEmpty": "Votre panier est vide",
|
||||||
"continueShopping": "Continuer les Achats",
|
"continueShopping": "Continuer les Achats",
|
||||||
"errorNoCheckout": "Pas de paiement actif. Veuillez réessayer.",
|
"errorNoCheckout": "Pas de paiement actif. Veuillez réessayer.",
|
||||||
|
"errorEmailRequired": "Veuillez entrer une adresse e-mail valide.",
|
||||||
|
"errorFieldsRequired": "Veuillez remplir tous les champs obligatoires.",
|
||||||
"errorOccurred": "Une erreur s'est produite lors du paiement.",
|
"errorOccurred": "Une erreur s'est produite lors du paiement.",
|
||||||
"errorCreatingOrder": "Échec de la création de la commande.",
|
"errorCreatingOrder": "Échec de la création de la commande.",
|
||||||
"orderConfirmed": "Commande Confirmée!",
|
"orderConfirmed": "Commande Confirmée!",
|
||||||
|
|||||||
@@ -386,7 +386,13 @@
|
|||||||
},
|
},
|
||||||
"Checkout": {
|
"Checkout": {
|
||||||
"checkout": "Kupovina",
|
"checkout": "Kupovina",
|
||||||
|
"contactInfo": "Kontakt informacije",
|
||||||
|
"email": "Email",
|
||||||
|
"emailRequired": "Potrebno za potvrdu narudžbine",
|
||||||
|
"phoneRequired": "Potrebno za koordinaciju dostave",
|
||||||
"shippingAddress": "Adresa za dostavu",
|
"shippingAddress": "Adresa za dostavu",
|
||||||
|
"shippingMethod": "Način dostave",
|
||||||
|
"country": "Država",
|
||||||
"firstName": "Ime",
|
"firstName": "Ime",
|
||||||
"lastName": "Prezime",
|
"lastName": "Prezime",
|
||||||
"streetAddress": "Ulica i broj",
|
"streetAddress": "Ulica i broj",
|
||||||
@@ -410,6 +416,8 @@
|
|||||||
"yourCartEmpty": "Vaša korpa je prazna",
|
"yourCartEmpty": "Vaša korpa je prazna",
|
||||||
"continueShopping": "Nastavi kupovinu",
|
"continueShopping": "Nastavi kupovinu",
|
||||||
"errorNoCheckout": "Nema aktivne korpe. Molimo pokušajte ponovo.",
|
"errorNoCheckout": "Nema aktivne korpe. Molimo pokušajte ponovo.",
|
||||||
|
"errorEmailRequired": "Molimo unesite validnu email adresu.",
|
||||||
|
"errorFieldsRequired": "Molimo popunite sva obavezna polja.",
|
||||||
"errorOccurred": "Došlo je do greške prilikom kupovine.",
|
"errorOccurred": "Došlo je do greške prilikom kupovine.",
|
||||||
"errorCreatingOrder": "Neuspešno kreiranje narudžbine.",
|
"errorCreatingOrder": "Neuspešno kreiranje narudžbine.",
|
||||||
"orderConfirmed": "Narudžbina potvrđena!",
|
"orderConfirmed": "Narudžbina potvrđena!",
|
||||||
|
|||||||
@@ -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;
|
||||||
+12
-6
@@ -1,4 +1,5 @@
|
|||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
|
import { render } from "@react-email/render";
|
||||||
|
|
||||||
let resendClient: Resend | null = null;
|
let resendClient: Resend | null = null;
|
||||||
|
|
||||||
@@ -30,17 +31,22 @@ export async function sendEmail({
|
|||||||
idempotencyKey?: string;
|
idempotencyKey?: string;
|
||||||
}) {
|
}) {
|
||||||
const resend = getResendClient();
|
const resend = getResendClient();
|
||||||
const { data, error } = await resend.emails.send(
|
|
||||||
{
|
// Render React component to HTML
|
||||||
from: "ManoonOils <[email protected]>",
|
const html = await render(react, {
|
||||||
|
pretty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, error } = await resend.emails.send({
|
||||||
|
from: "ManoonOils <[email protected]>",
|
||||||
|
replyTo: "[email protected]",
|
||||||
to: Array.isArray(to) ? to : [to],
|
to: Array.isArray(to) ? to : [to],
|
||||||
subject,
|
subject,
|
||||||
react,
|
html,
|
||||||
text,
|
text,
|
||||||
tags,
|
tags,
|
||||||
...(idempotencyKey && { idempotencyKey }),
|
...(idempotencyKey && { idempotencyKey }),
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error("Failed to send email:", error);
|
console.error("Failed to send email:", error);
|
||||||
|
|||||||
@@ -152,3 +152,24 @@ export const CHECKOUT_EMAIL_UPDATE = gql`
|
|||||||
}
|
}
|
||||||
${CHECKOUT_FRAGMENT}
|
${CHECKOUT_FRAGMENT}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const CHECKOUT_METADATA_UPDATE = gql`
|
||||||
|
mutation CheckoutMetadataUpdate($checkoutId: ID!, $metadata: [MetadataInput!]!) {
|
||||||
|
updateMetadata(id: $checkoutId, input: $metadata) {
|
||||||
|
item {
|
||||||
|
... on Checkout {
|
||||||
|
id
|
||||||
|
metadata {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -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