Compare commits
20 Commits
feature/i1
...
feature/sa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf628f873f | ||
|
|
eb311568db | ||
|
|
c9aaacc452 | ||
|
|
e08e919e83 | ||
|
|
923f805d47 | ||
|
|
6e0a05c314 | ||
|
|
5576946829 | ||
|
|
ef83538d0b | ||
|
|
4fcd4b3ba8 | ||
|
|
b8b3a57e6f | ||
|
|
00f63c32f8 | ||
|
|
3d8a77dafa | ||
|
|
bfce7dcca0 | ||
|
|
8f780c3585 | ||
|
|
9a61564e3c | ||
|
|
28a6e58dba | ||
|
|
569a3e65fe | ||
|
|
1ba81a1fde | ||
|
|
df95e729fc | ||
|
|
513dcb7fea |
@@ -76,6 +76,8 @@ 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"
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: workspace
|
- name: workspace
|
||||||
mountPath: /workspace
|
mountPath: /workspace
|
||||||
@@ -108,6 +110,10 @@ 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"
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpu: 500m
|
cpu: 500m
|
||||||
|
|||||||
615
package-lock.json
generated
615
package-lock.json
generated
@@ -9,6 +9,8 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^4.1.6",
|
"@apollo/client": "^4.1.6",
|
||||||
|
"@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",
|
||||||
@@ -17,6 +19,7 @@
|
|||||||
"next-intl": "^4.8.3",
|
"next-intl": "^4.8.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"resend": "^6.9.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
@@ -1644,6 +1647,343 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-email/body": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/button": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/code-block": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prismjs": "^1.30.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/code-inline": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/column": {
|
||||||
|
"version": "0.0.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.14.tgz",
|
||||||
|
"integrity": "sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/components": {
|
||||||
|
"version": "1.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/components/-/components-1.0.10.tgz",
|
||||||
|
"integrity": "sha512-r/BnqfAjr3apcvn/NDx2DqNRD5BP5wZLRdjn2IVHXjt4KmQ5RHWSCAvFiXAzRHys1BWQ2zgIc7cpWePUcAl+nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-email/body": "0.3.0",
|
||||||
|
"@react-email/button": "0.2.1",
|
||||||
|
"@react-email/code-block": "0.2.1",
|
||||||
|
"@react-email/code-inline": "0.0.6",
|
||||||
|
"@react-email/column": "0.0.14",
|
||||||
|
"@react-email/container": "0.0.16",
|
||||||
|
"@react-email/font": "0.0.10",
|
||||||
|
"@react-email/head": "0.0.13",
|
||||||
|
"@react-email/heading": "0.0.16",
|
||||||
|
"@react-email/hr": "0.0.12",
|
||||||
|
"@react-email/html": "0.0.12",
|
||||||
|
"@react-email/img": "0.0.12",
|
||||||
|
"@react-email/link": "0.0.13",
|
||||||
|
"@react-email/markdown": "0.0.18",
|
||||||
|
"@react-email/preview": "0.0.14",
|
||||||
|
"@react-email/render": "2.0.4",
|
||||||
|
"@react-email/row": "0.0.13",
|
||||||
|
"@react-email/section": "0.0.17",
|
||||||
|
"@react-email/tailwind": "2.0.6",
|
||||||
|
"@react-email/text": "0.1.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/container": {
|
||||||
|
"version": "0.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.16.tgz",
|
||||||
|
"integrity": "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/font": {
|
||||||
|
"version": "0.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.10.tgz",
|
||||||
|
"integrity": "sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/head": {
|
||||||
|
"version": "0.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.13.tgz",
|
||||||
|
"integrity": "sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/heading": {
|
||||||
|
"version": "0.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.16.tgz",
|
||||||
|
"integrity": "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/hr": {
|
||||||
|
"version": "0.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.12.tgz",
|
||||||
|
"integrity": "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/html": {
|
||||||
|
"version": "0.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.12.tgz",
|
||||||
|
"integrity": "sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/img": {
|
||||||
|
"version": "0.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.12.tgz",
|
||||||
|
"integrity": "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/link": {
|
||||||
|
"version": "0.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.13.tgz",
|
||||||
|
"integrity": "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/markdown": {
|
||||||
|
"version": "0.0.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.18.tgz",
|
||||||
|
"integrity": "sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"marked": "^15.0.12"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/preview": {
|
||||||
|
"version": "0.0.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.14.tgz",
|
||||||
|
"integrity": "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"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": {
|
||||||
|
"version": "0.0.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.13.tgz",
|
||||||
|
"integrity": "sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/section": {
|
||||||
|
"version": "0.0.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.17.tgz",
|
||||||
|
"integrity": "sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/tailwind": {
|
||||||
|
"version": "2.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-2.0.6.tgz",
|
||||||
|
"integrity": "sha512-3PgL/GYWmgS+puLPQ2aLlsplHSOFztRl70fowBkbLIb8ZUIgvx5YId6zYCCHeM2+DQ/EG3iXXqLNTahVztuMqQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tailwindcss": "4.1.18"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@react-email/body": ">=0",
|
||||||
|
"@react-email/button": ">=0",
|
||||||
|
"@react-email/code-block": ">=0",
|
||||||
|
"@react-email/code-inline": ">=0",
|
||||||
|
"@react-email/container": ">=0",
|
||||||
|
"@react-email/heading": ">=0",
|
||||||
|
"@react-email/hr": ">=0",
|
||||||
|
"@react-email/img": ">=0",
|
||||||
|
"@react-email/link": ">=0",
|
||||||
|
"@react-email/preview": ">=0",
|
||||||
|
"@react-email/text": ">=0",
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@react-email/body": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@react-email/button": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@react-email/code-block": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@react-email/code-inline": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@react-email/container": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@react-email/heading": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@react-email/hr": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@react-email/img": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@react-email/link": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@react-email/preview": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/tailwind/node_modules/tailwindcss": {
|
||||||
|
"version": "4.1.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||||
|
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-email/text": {
|
||||||
|
"version": "0.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
|
||||||
|
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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",
|
||||||
@@ -1657,6 +1997,25 @@
|
|||||||
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
|
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@selderee/plugin-htmlparser2": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"selderee": "^0.11.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/killymxi"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/core-darwin-arm64": {
|
"node_modules/@swc/core-darwin-arm64": {
|
||||||
"version": "1.15.18",
|
"version": "1.15.18",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.18.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.18.tgz",
|
||||||
@@ -3409,6 +3768,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/deepmerge": {
|
||||||
|
"version": "4.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/define-data-property": {
|
"node_modules/define-data-property": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||||
@@ -3467,6 +3835,61 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -3510,6 +3933,18 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-abstract": {
|
"node_modules/es-abstract": {
|
||||||
"version": "1.24.1",
|
"version": "1.24.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
|
||||||
@@ -4191,6 +4626,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.20.1",
|
"version": "1.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
@@ -4637,6 +5078,41 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-to-text": {
|
||||||
|
"version": "9.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||||
|
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@selderee/plugin-htmlparser2": "^0.11.0",
|
||||||
|
"deepmerge": "^4.3.1",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"htmlparser2": "^8.0.2",
|
||||||
|
"selderee": "^0.11.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"entities": "^4.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/icu-minify": {
|
"node_modules/icu-minify": {
|
||||||
"version": "4.8.3",
|
"version": "4.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.3.tgz",
|
||||||
@@ -5284,6 +5760,15 @@
|
|||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leac": {
|
||||||
|
"version": "0.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||||
|
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/killymxi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/levn": {
|
"node_modules/levn": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||||
@@ -5624,6 +6109,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/marked": {
|
||||||
|
"version": "15.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
|
||||||
|
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"marked": "bin/marked.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -6158,6 +6655,19 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parseley": {
|
||||||
|
"version": "0.12.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
|
||||||
|
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"leac": "^0.6.0",
|
||||||
|
"peberminta": "^0.9.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/killymxi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -6185,6 +6695,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/peberminta": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/killymxi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -6220,6 +6739,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postal-mime": {
|
||||||
|
"version": "2.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz",
|
||||||
|
"integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==",
|
||||||
|
"license": "MIT-0"
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"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",
|
||||||
@@ -6259,6 +6784,30 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prettier": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"prettier": "bin/prettier.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prismjs": {
|
||||||
|
"version": "1.30.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||||
|
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -6374,6 +6923,27 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resend": {
|
||||||
|
"version": "6.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/resend/-/resend-6.9.4.tgz",
|
||||||
|
"integrity": "sha512-/M3dsJzu5OgozqVsA4Psd/1L7EdePgOIIxClas453GOQYFG3VHc2ZyCHZFlvqsc9aZCCd2BJRRqZgWC8D9c7/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"postal-mime": "2.7.3",
|
||||||
|
"svix": "1.86.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@react-email/render": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@react-email/render": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -6521,6 +7091,18 @@
|
|||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/selderee": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parseley": "^0.12.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://ko-fi.com/killymxi"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
@@ -6753,6 +7335,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stop-iteration-iterator": {
|
"node_modules/stop-iteration-iterator": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
|
||||||
@@ -6952,6 +7544,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svix": {
|
||||||
|
"version": "1.86.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/svix/-/svix-1.86.0.tgz",
|
||||||
|
"integrity": "sha512-/HTvXwjLJe1l/MsLXAO1ddCYxElJk4eNR4DzOjDOEmGrPN/3BtBE8perGwMAaJ2sT5T172VkBYzmHcjUfM1JRQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"standardwebhooks": "1.0.0",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwind-merge": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.5.0",
|
"version": "3.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||||
@@ -7341,6 +7943,19 @@
|
|||||||
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
"react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^4.1.6",
|
"@apollo/client": "^4.1.6",
|
||||||
|
"@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",
|
||||||
@@ -18,6 +20,7 @@
|
|||||||
"next-intl": "^4.8.3",
|
"next-intl": "^4.8.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"resend": "^6.9.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
<div className="relative h-[400px] md:h-[500px] overflow-hidden">
|
<div className="relative h-[400px] md:h-[500px] overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop"
|
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop"
|
||||||
alt={metadata.productionAlt}
|
alt={metadata.about.productionAlt}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/20" />
|
<div className="absolute inset-0 bg-black/20" />
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ 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 +42,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 +86,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() {
|
||||||
@@ -66,7 +109,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 +120,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 +138,16 @@ export default function CheckoutPage() {
|
|||||||
}
|
}
|
||||||
}, [checkout, refreshCheckout]);
|
}, [checkout, refreshCheckout]);
|
||||||
|
|
||||||
|
// 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 +156,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,61 +168,183 @@ 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: {
|
console.log("Step 1: Updating billing address...");
|
||||||
...shippingAddress,
|
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
||||||
country: "RS",
|
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||||
|
variables: {
|
||||||
|
checkoutId: checkout.id,
|
||||||
|
billingAddress: {
|
||||||
|
firstName: billingAddress.firstName,
|
||||||
|
lastName: billingAddress.lastName,
|
||||||
|
streetAddress1: billingAddress.streetAddress1,
|
||||||
|
streetAddress2: billingAddress.streetAddress2,
|
||||||
|
city: billingAddress.city,
|
||||||
|
postalCode: billingAddress.postalCode,
|
||||||
|
country: billingAddress.country,
|
||||||
|
phone: billingAddress.phone,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
|
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
|
||||||
throw new Error(shippingResult.data.checkoutShippingAddressUpdate.errors[0].message);
|
throw new Error(`Billing address update failed: ${billingResult.data.checkoutBillingAddressUpdate.errors[0].message}`);
|
||||||
}
|
}
|
||||||
|
console.log("Step 1: Billing address updated successfully");
|
||||||
|
|
||||||
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
console.log("Step 2: Setting shipping method...");
|
||||||
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
const shippingMethodResult = await saleorClient.mutate<ShippingMethodUpdateResponse>({
|
||||||
variables: {
|
mutation: CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||||
checkoutId: checkout.id,
|
variables: {
|
||||||
billingAddress: {
|
checkoutId: checkout.id,
|
||||||
...billingAddress,
|
shippingMethodId: selectedShippingMethod,
|
||||||
country: "RS",
|
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
|
if (shippingMethodResult.data?.checkoutShippingMethodUpdate?.errors && shippingMethodResult.data.checkoutShippingMethodUpdate.errors.length > 0) {
|
||||||
throw new Error(billingResult.data.checkoutBillingAddressUpdate.errors[0].message);
|
throw new Error(`Shipping method update failed: ${shippingMethodResult.data.checkoutShippingMethodUpdate.errors[0].message}`);
|
||||||
}
|
}
|
||||||
|
console.log("Step 2: Shipping method set successfully");
|
||||||
|
|
||||||
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
|
console.log("Step 3: Saving phone number...");
|
||||||
mutation: CHECKOUT_COMPLETE,
|
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
|
||||||
variables: {
|
mutation: CHECKOUT_METADATA_UPDATE,
|
||||||
checkoutId: checkout.id,
|
variables: {
|
||||||
},
|
checkoutId: checkout.id,
|
||||||
});
|
metadata: [
|
||||||
|
{ key: "phone", value: shippingAddress.phone },
|
||||||
|
{ key: "shippingPhone", value: shippingAddress.phone },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (completeResult.data?.checkoutComplete?.errors && completeResult.data.checkoutComplete.errors.length > 0) {
|
if (metadataResult.data?.updateMetadata?.errors && metadataResult.data.updateMetadata.errors.length > 0) {
|
||||||
throw new Error(completeResult.data.checkoutComplete.errors[0].message);
|
console.warn("Failed to save phone metadata:", metadataResult.data.updateMetadata.errors);
|
||||||
}
|
} else {
|
||||||
|
console.log("Step 3: Phone number saved successfully");
|
||||||
|
}
|
||||||
|
|
||||||
const order = completeResult.data?.checkoutComplete?.order;
|
console.log("Step 4: Completing checkout...");
|
||||||
if (order) {
|
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
|
||||||
setOrderNumber(order.number);
|
mutation: CHECKOUT_COMPLETE,
|
||||||
setOrderComplete(true);
|
variables: {
|
||||||
|
checkoutId: checkout.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (completeResult.data?.checkoutComplete?.errors && completeResult.data.checkoutComplete.errors.length > 0) {
|
||||||
|
throw new Error(completeResult.data.checkoutComplete.errors[0].message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = completeResult.data?.checkoutComplete?.order;
|
||||||
|
if (order) {
|
||||||
|
setOrderNumber(order.number);
|
||||||
|
setOrderComplete(true);
|
||||||
|
} else {
|
||||||
|
throw new Error(t("errorCreatingOrder"));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(t("errorCreatingOrder"));
|
// 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);
|
||||||
|
|
||||||
|
// 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 +411,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@example.com"
|
||||||
|
/>
|
||||||
|
<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 +464,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 +532,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 +547,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>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getProducts } from "@/lib/saleor";
|
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
import Header from "@/components/layout/Header";
|
import Header from "@/components/layout/Header";
|
||||||
import Footer from "@/components/layout/Footer";
|
import Footer from "@/components/layout/Footer";
|
||||||
@@ -40,7 +40,8 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
|
|||||||
console.log("Failed to fetch products during build");
|
console.log("Failed to fetch products during build");
|
||||||
}
|
}
|
||||||
|
|
||||||
const featuredProducts = products?.slice(0, 4) || [];
|
const filteredProducts = filterOutBundles(products);
|
||||||
|
const featuredProducts = filteredProducts.slice(0, 4);
|
||||||
const hasProducts = featuredProducts.length > 0;
|
const hasProducts = featuredProducts.length > 0;
|
||||||
|
|
||||||
const basePath = `/${validLocale}`;
|
const basePath = `/${validLocale}`;
|
||||||
@@ -206,7 +207,7 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
|
|||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder={t("emailPlaceholder")}
|
placeholder={t("emailPlaceholder")}
|
||||||
className="flex-1 min-w-0 px-5 !h-14 bg-white/10 border border-white/20 border-b-0 sm:border-b border-r-0 sm:border-r border-white/20 text-white placeholder:text-white/50 focus:border-white focus:outline-none transition-colors text-base text-center sm:text-left rounded-t sm:rounded-l sm:rounded-tr-none"
|
className="flex-1 min-w-0 px-5 !h-16 bg-white/10 border border-white/20 border-b-0 sm:border-b border-r-0 sm:border-r border-white/20 text-white placeholder:text-white/50 focus:border-white focus:outline-none transition-colors text-base text-center sm:text-left rounded-t sm:rounded-l sm:rounded-tr-none"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getProductBySlug, getProducts, getLocalizedProduct } from "@/lib/saleor";
|
import { getProductBySlug, getProducts, getLocalizedProduct, getBundleProducts, filterOutBundles } from "@/lib/saleor";
|
||||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
import Header from "@/components/layout/Header";
|
import Header from "@/components/layout/Header";
|
||||||
import Footer from "@/components/layout/Footer";
|
import Footer from "@/components/layout/Footer";
|
||||||
@@ -20,7 +20,8 @@ export async function generateStaticParams() {
|
|||||||
try {
|
try {
|
||||||
const saleorLocale = locale === "sr" ? "SR" : "EN";
|
const saleorLocale = locale === "sr" ? "SR" : "EN";
|
||||||
const products = await getProducts(saleorLocale, 100);
|
const products = await getProducts(saleorLocale, 100);
|
||||||
products.forEach((product: Product) => {
|
const filteredProducts = filterOutBundles(products);
|
||||||
|
filteredProducts.forEach((product: Product) => {
|
||||||
params.push({ locale, slug: product.slug });
|
params.push({ locale, slug: product.slug });
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -86,13 +87,27 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let relatedProducts: Product[] = [];
|
let relatedProducts: Product[] = [];
|
||||||
|
let bundleProducts: Product[] = [];
|
||||||
try {
|
try {
|
||||||
const allProducts = await getProducts(productLocale, 8);
|
const allProducts = await getProducts(saleorLocale, 50);
|
||||||
relatedProducts = allProducts
|
relatedProducts = filterOutBundles(allProducts)
|
||||||
.filter((p: Product) => p.id !== product.id)
|
.filter((p: Product) => p.id !== product.id)
|
||||||
.slice(0, 4);
|
.slice(0, 4);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allBundleProducts = await getBundleProducts(saleorLocale, 50);
|
||||||
|
bundleProducts = allBundleProducts.filter((p) => {
|
||||||
|
const bundleAttr = p.attributes?.find(
|
||||||
|
(attr) => attr.attribute.slug === "bundle-items"
|
||||||
|
);
|
||||||
|
if (!bundleAttr || bundleAttr.values.length === 0) return false;
|
||||||
|
return bundleAttr.values.some((val) => {
|
||||||
|
return val.name === product.name || p.name.includes(product.name.split(" - ")[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header locale={locale} />
|
<Header locale={locale} />
|
||||||
@@ -100,6 +115,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<ProductDetail
|
<ProductDetail
|
||||||
product={product}
|
product={product}
|
||||||
relatedProducts={relatedProducts}
|
relatedProducts={relatedProducts}
|
||||||
|
bundleProducts={bundleProducts}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getProducts } from "@/lib/saleor";
|
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
import Header from "@/components/layout/Header";
|
import Header from "@/components/layout/Header";
|
||||||
import Footer from "@/components/layout/Footer";
|
import Footer from "@/components/layout/Footer";
|
||||||
@@ -27,7 +27,9 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
setRequestLocale(validLocale);
|
setRequestLocale(validLocale);
|
||||||
const t = await getTranslations("Products");
|
const t = await getTranslations("Products");
|
||||||
const saleorLocale = getSaleorLocale(validLocale as Locale);
|
const saleorLocale = getSaleorLocale(validLocale as Locale);
|
||||||
const products = await getProducts(saleorLocale);
|
const allProducts = await getProducts(saleorLocale);
|
||||||
|
|
||||||
|
const products = filterOutBundles(allProducts);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -86,7 +88,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
key={product.id}
|
key={product.id}
|
||||||
product={product}
|
product={product}
|
||||||
index={index}
|
index={index}
|
||||||
locale={productLocale}
|
locale={validLocale}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
562
src/app/api/webhooks/saleor/route.ts
Normal file
562
src/app/api/webhooks/saleor/route.ts
Normal file
@@ -0,0 +1,562 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { sendEmailToCustomer, sendEmailToAdmin } from "@/lib/resend";
|
||||||
|
import { OrderConfirmation } from "@/emails/OrderConfirmation";
|
||||||
|
import { OrderShipped } from "@/emails/OrderShipped";
|
||||||
|
import { OrderCancelled } from "@/emails/OrderCancelled";
|
||||||
|
import { OrderPaid } from "@/emails/OrderPaid";
|
||||||
|
|
||||||
|
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||||
|
const DASHBOARD_URL = process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com";
|
||||||
|
|
||||||
|
interface SaleorWebhookHeaders {
|
||||||
|
"saleor-event": string;
|
||||||
|
"saleor-domain": string;
|
||||||
|
"saleor-signature"?: string;
|
||||||
|
"saleor-api-url": string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saleor sends snake_case in webhook payloads
|
||||||
|
interface SaleorLineItemPayload {
|
||||||
|
id: string;
|
||||||
|
product_name: string;
|
||||||
|
variant_name?: string;
|
||||||
|
quantity: number;
|
||||||
|
total_price_gross_amount: string;
|
||||||
|
currency: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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; // Fallback for line items
|
||||||
|
language_code?: string;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal camelCase interfaces for our code
|
||||||
|
interface SaleorLineItem {
|
||||||
|
id: string;
|
||||||
|
productName: string;
|
||||||
|
variantName?: string;
|
||||||
|
quantity: number;
|
||||||
|
totalPrice: {
|
||||||
|
gross: {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaleorAddress {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
streetAddress1?: string;
|
||||||
|
streetAddress2?: string;
|
||||||
|
city?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
country?: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SaleorOrder {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
userEmail: string;
|
||||||
|
user?: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
};
|
||||||
|
billingAddress?: SaleorAddress;
|
||||||
|
shippingAddress?: SaleorAddress;
|
||||||
|
lines: SaleorLineItem[];
|
||||||
|
total: {
|
||||||
|
gross: {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
shippingPrice?: {
|
||||||
|
gross: {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
languageCode?: string;
|
||||||
|
metadata?: Array<{ key: string; value: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPORTED_EVENTS = [
|
||||||
|
"ORDER_CREATED",
|
||||||
|
"ORDER_CONFIRMED",
|
||||||
|
"ORDER_FULLY_PAID",
|
||||||
|
"ORDER_CANCELLED",
|
||||||
|
"ORDER_FULFILLED",
|
||||||
|
];
|
||||||
|
|
||||||
|
const LANGUAGE_CODE_MAP: Record<string, string> = {
|
||||||
|
SR: "sr",
|
||||||
|
EN: "en",
|
||||||
|
DE: "de",
|
||||||
|
FR: "fr",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert Saleor snake_case payload to camelCase
|
||||||
|
function convertPayloadToOrder(payload: SaleorOrderPayload): SaleorOrder {
|
||||||
|
return {
|
||||||
|
id: payload.id,
|
||||||
|
number: String(payload.number),
|
||||||
|
userEmail: payload.user_email,
|
||||||
|
user: payload.first_name || payload.last_name ? {
|
||||||
|
firstName: payload.first_name,
|
||||||
|
lastName: payload.last_name,
|
||||||
|
email: payload.user_email,
|
||||||
|
} : undefined,
|
||||||
|
billingAddress: payload.billing_address ? {
|
||||||
|
firstName: payload.billing_address.first_name,
|
||||||
|
lastName: payload.billing_address.last_name,
|
||||||
|
streetAddress1: payload.billing_address.street_address_1,
|
||||||
|
streetAddress2: payload.billing_address.street_address_2,
|
||||||
|
city: payload.billing_address.city,
|
||||||
|
postalCode: payload.billing_address.postal_code,
|
||||||
|
country: payload.billing_address.country,
|
||||||
|
phone: payload.billing_address.phone,
|
||||||
|
} : undefined,
|
||||||
|
shippingAddress: payload.shipping_address ? {
|
||||||
|
firstName: payload.shipping_address.first_name,
|
||||||
|
lastName: payload.shipping_address.last_name,
|
||||||
|
streetAddress1: payload.shipping_address.street_address_1,
|
||||||
|
streetAddress2: payload.shipping_address.street_address_2,
|
||||||
|
city: payload.shipping_address.city,
|
||||||
|
postalCode: payload.shipping_address.postal_code,
|
||||||
|
country: payload.shipping_address.country,
|
||||||
|
phone: payload.shipping_address.phone,
|
||||||
|
} : undefined,
|
||||||
|
lines: payload.lines.map((line) => ({
|
||||||
|
id: line.id,
|
||||||
|
productName: line.product_name,
|
||||||
|
variantName: line.variant_name,
|
||||||
|
quantity: line.quantity,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shippingPrice: payload.shipping_price_gross_amount ? {
|
||||||
|
gross: {
|
||||||
|
amount: parseInt(payload.shipping_price_gross_amount),
|
||||||
|
currency: payload.channel.currency_code,
|
||||||
|
},
|
||||||
|
} : undefined,
|
||||||
|
languageCode: payload.language_code?.toUpperCase(),
|
||||||
|
metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCustomerLanguage(order: SaleorOrder): string {
|
||||||
|
if (order.languageCode && LANGUAGE_CODE_MAP[order.languageCode]) {
|
||||||
|
return LANGUAGE_CODE_MAP[order.languageCode];
|
||||||
|
}
|
||||||
|
if (order.metadata) {
|
||||||
|
const langMeta = order.metadata.find((m) => m.key === "language");
|
||||||
|
if (langMeta && LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]) {
|
||||||
|
return LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(amount: number, currency: string): string {
|
||||||
|
return new Intl.NumberFormat("sr-RS", {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency,
|
||||||
|
}).format(amount / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddress(address?: SaleorAddress): string {
|
||||||
|
if (!address) return "";
|
||||||
|
const parts = [
|
||||||
|
address.firstName,
|
||||||
|
address.lastName,
|
||||||
|
address.streetAddress1,
|
||||||
|
address.streetAddress2,
|
||||||
|
address.postalCode,
|
||||||
|
address.city,
|
||||||
|
address.country,
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCustomerName(order: SaleorOrder): string {
|
||||||
|
if (order.user?.firstName) {
|
||||||
|
return `${order.user.firstName}${order.user.lastName ? ` ${order.user.lastName}` : ""}`;
|
||||||
|
}
|
||||||
|
if (order.billingAddress?.firstName) {
|
||||||
|
return `${order.billingAddress.firstName}${order.billingAddress.lastName ? ` ${order.billingAddress.lastName}` : ""}`;
|
||||||
|
}
|
||||||
|
return "Customer";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOrderItems(lines: SaleorLineItem[], currency: string) {
|
||||||
|
return lines.map((line) => ({
|
||||||
|
id: line.id,
|
||||||
|
name: line.variantName ? `${line.productName} (${line.variantName})` : line.productName,
|
||||||
|
quantity: line.quantity,
|
||||||
|
price: formatPrice(line.totalPrice.gross.amount, currency),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOrderConfirmed(order: SaleorOrder, eventType: string) {
|
||||||
|
const language = getCustomerLanguage(order);
|
||||||
|
const currency = order.total.gross.currency;
|
||||||
|
const customerName = getCustomerName(order);
|
||||||
|
|
||||||
|
const customerEmail = order.userEmail;
|
||||||
|
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
|
||||||
|
|
||||||
|
// Only send customer email for ORDER_CONFIRMED, not ORDER_CREATED
|
||||||
|
// This prevents duplicate emails when both events fire
|
||||||
|
if (eventType === "ORDER_CONFIRMED") {
|
||||||
|
await sendEmailToCustomer({
|
||||||
|
to: customerEmail,
|
||||||
|
subject:
|
||||||
|
language === "sr"
|
||||||
|
? `Potvrda narudžbine #${order.number}`
|
||||||
|
: language === "de"
|
||||||
|
? `Bestellbestätigung #${order.number}`
|
||||||
|
: language === "fr"
|
||||||
|
? `Confirmation de commande #${order.number}`
|
||||||
|
: `Order Confirmation #${order.number}`,
|
||||||
|
react: OrderConfirmation({
|
||||||
|
language,
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.number,
|
||||||
|
customerEmail,
|
||||||
|
customerName,
|
||||||
|
items: parseOrderItems(order.lines, currency),
|
||||||
|
total: formatPrice(order.total.gross.amount, currency),
|
||||||
|
shippingAddress: formatAddress(order.shippingAddress),
|
||||||
|
siteUrl: SITE_URL,
|
||||||
|
}),
|
||||||
|
language,
|
||||||
|
idempotencyKey: `order-confirmed/${order.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always send admin notification for both ORDER_CREATED and ORDER_CONFIRMED
|
||||||
|
await sendEmailToAdmin({
|
||||||
|
subject: `🎉 New Order #${order.number} - ${formatPrice(order.total.gross.amount, currency)}`,
|
||||||
|
react: OrderConfirmation({
|
||||||
|
language: "en",
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.number,
|
||||||
|
customerEmail,
|
||||||
|
customerName,
|
||||||
|
items: parseOrderItems(order.lines, currency),
|
||||||
|
total: formatPrice(order.total.gross.amount, currency),
|
||||||
|
shippingAddress: formatAddress(order.shippingAddress),
|
||||||
|
billingAddress: formatAddress(order.billingAddress),
|
||||||
|
phone,
|
||||||
|
siteUrl: SITE_URL,
|
||||||
|
dashboardUrl: DASHBOARD_URL,
|
||||||
|
isAdmin: true,
|
||||||
|
}),
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
orderId: order.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOrderFulfilled(order: SaleorOrder) {
|
||||||
|
const language = getCustomerLanguage(order);
|
||||||
|
const currency = order.total.gross.currency;
|
||||||
|
const customerName = getCustomerName(order);
|
||||||
|
const customerEmail = order.userEmail;
|
||||||
|
|
||||||
|
let trackingNumber: string | undefined;
|
||||||
|
let trackingUrl: string | undefined;
|
||||||
|
|
||||||
|
if (order.metadata) {
|
||||||
|
const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber");
|
||||||
|
if (trackingMeta) {
|
||||||
|
trackingNumber = trackingMeta.value;
|
||||||
|
}
|
||||||
|
const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl");
|
||||||
|
if (trackingUrlMeta) {
|
||||||
|
trackingUrl = trackingUrlMeta.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendEmailToCustomer({
|
||||||
|
to: customerEmail,
|
||||||
|
subject:
|
||||||
|
language === "sr"
|
||||||
|
? `Vaša narudžbina #${order.number} je poslata!`
|
||||||
|
: language === "de"
|
||||||
|
? `Ihre Bestellung #${order.number} wurde versendet!`
|
||||||
|
: language === "fr"
|
||||||
|
? `Votre commande #${order.number} a été expédiée!`
|
||||||
|
: `Your Order #${order.number} Has Shipped!`,
|
||||||
|
react: OrderShipped({
|
||||||
|
language,
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.number,
|
||||||
|
customerName,
|
||||||
|
items: parseOrderItems(order.lines, currency),
|
||||||
|
trackingNumber,
|
||||||
|
trackingUrl,
|
||||||
|
siteUrl: SITE_URL,
|
||||||
|
}),
|
||||||
|
language,
|
||||||
|
idempotencyKey: `order-fulfilled/${order.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendEmailToAdmin({
|
||||||
|
subject: `Order Shipped #${order.number} - ${customerName}`,
|
||||||
|
react: OrderShipped({
|
||||||
|
language: "en",
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.number,
|
||||||
|
customerName,
|
||||||
|
items: parseOrderItems(order.lines, currency),
|
||||||
|
trackingNumber,
|
||||||
|
trackingUrl,
|
||||||
|
siteUrl: SITE_URL,
|
||||||
|
}),
|
||||||
|
eventType: "ORDER_FULFILLED",
|
||||||
|
orderId: order.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOrderCancelled(order: SaleorOrder) {
|
||||||
|
const language = getCustomerLanguage(order);
|
||||||
|
const currency = order.total.gross.currency;
|
||||||
|
const customerName = getCustomerName(order);
|
||||||
|
const customerEmail = order.userEmail;
|
||||||
|
|
||||||
|
let reason: string | undefined;
|
||||||
|
if (order.metadata) {
|
||||||
|
const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason");
|
||||||
|
if (reasonMeta) {
|
||||||
|
reason = reasonMeta.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendEmailToCustomer({
|
||||||
|
to: customerEmail,
|
||||||
|
subject:
|
||||||
|
language === "sr"
|
||||||
|
? `Vaša narudžbina #${order.number} je otkazana`
|
||||||
|
: language === "de"
|
||||||
|
? `Ihre Bestellung #${order.number} wurde storniert`
|
||||||
|
: language === "fr"
|
||||||
|
? `Votre commande #${order.number} a été annulée`
|
||||||
|
: `Your Order #${order.number} Has Been Cancelled`,
|
||||||
|
react: OrderCancelled({
|
||||||
|
language,
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.number,
|
||||||
|
customerName,
|
||||||
|
items: parseOrderItems(order.lines, currency),
|
||||||
|
total: formatPrice(order.total.gross.amount, currency),
|
||||||
|
reason,
|
||||||
|
siteUrl: SITE_URL,
|
||||||
|
}),
|
||||||
|
language,
|
||||||
|
idempotencyKey: `order-cancelled/${order.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendEmailToAdmin({
|
||||||
|
subject: `Order Cancelled #${order.number} - ${customerName}`,
|
||||||
|
react: OrderCancelled({
|
||||||
|
language: "en",
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.number,
|
||||||
|
customerName,
|
||||||
|
items: parseOrderItems(order.lines, currency),
|
||||||
|
total: formatPrice(order.total.gross.amount, currency),
|
||||||
|
reason,
|
||||||
|
siteUrl: SITE_URL,
|
||||||
|
}),
|
||||||
|
eventType: "ORDER_CANCELLED",
|
||||||
|
orderId: order.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOrderFullyPaid(order: SaleorOrder) {
|
||||||
|
const language = getCustomerLanguage(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(
|
||||||
|
event: string,
|
||||||
|
payload: { order: SaleorOrder }
|
||||||
|
) {
|
||||||
|
const { order } = payload;
|
||||||
|
|
||||||
|
console.log(`Processing webhook event: ${event} for order ${order?.id}`);
|
||||||
|
|
||||||
|
if (!order || !order.id) {
|
||||||
|
console.error("No order in payload");
|
||||||
|
throw new Error("No order in payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event) {
|
||||||
|
case "ORDER_CREATED":
|
||||||
|
case "ORDER_CONFIRMED":
|
||||||
|
await handleOrderConfirmed(order, event);
|
||||||
|
break;
|
||||||
|
case "ORDER_FULFILLED":
|
||||||
|
await handleOrderFulfilled(order);
|
||||||
|
break;
|
||||||
|
case "ORDER_CANCELLED":
|
||||||
|
await handleOrderCancelled(order);
|
||||||
|
break;
|
||||||
|
case "ORDER_FULLY_PAID":
|
||||||
|
await handleOrderFullyPaid(order);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(`Unsupported event: ${event}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
console.log("=== WEBHOOK RECEIVED ===");
|
||||||
|
console.log("Timestamp:", new Date().toISOString());
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const headers = request.headers;
|
||||||
|
|
||||||
|
const event = headers.get("saleor-event") as string;
|
||||||
|
const domain = headers.get("saleor-domain");
|
||||||
|
const signature = headers.get("saleor-signature");
|
||||||
|
const apiUrl = headers.get("saleor-api-url");
|
||||||
|
|
||||||
|
console.log(`Received webhook: ${event} from ${domain}`);
|
||||||
|
console.log("Headers:", { event, domain, apiUrl, hasSignature: !!signature });
|
||||||
|
console.log("Payload:", JSON.stringify(body).substring(0, 500));
|
||||||
|
|
||||||
|
// Handle Saleor legacy webhook payload format (array with snake_case fields)
|
||||||
|
let orderPayload: SaleorOrderPayload | null = null;
|
||||||
|
if (Array.isArray(body) && body.length > 0) {
|
||||||
|
// Legacy format: array with order objects directly
|
||||||
|
orderPayload = body[0] as SaleorOrderPayload;
|
||||||
|
} else if (body.data && Array.isArray(body.data)) {
|
||||||
|
// Subscription format: { data: [...] }
|
||||||
|
orderPayload = body.data[0] as SaleorOrderPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
console.log("Order number:", orderPayload.number);
|
||||||
|
console.log("User email:", orderPayload.user_email);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize event to uppercase for comparison
|
||||||
|
const normalizedEvent = event.toUpperCase();
|
||||||
|
|
||||||
|
if (!SUPPORTED_EVENTS.includes(normalizedEvent)) {
|
||||||
|
console.log(`Event ${event} (normalized: ${normalizedEvent}) not supported, skipping`);
|
||||||
|
return NextResponse.json({ success: true, message: "Event not supported" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert snake_case payload to camelCase
|
||||||
|
const order = convertPayloadToOrder(orderPayload);
|
||||||
|
|
||||||
|
await handleSaleorWebhook(normalizedEvent, { order });
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Webhook processing error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Internal server error", details: String(error) },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
status: "ok",
|
||||||
|
message: "Saleor webhook endpoint is active",
|
||||||
|
supportedEvents: SUPPORTED_EVENTS,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MetadataRoute } from "next";
|
import { MetadataRoute } from "next";
|
||||||
import { getProducts } from "@/lib/saleor";
|
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||||
import { SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/locales";
|
import { SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||||
@@ -80,9 +80,11 @@ export default async function sitemap(): Promise<SitemapEntry[]> {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const filteredProducts = filterOutBundles(products);
|
||||||
|
|
||||||
const productUrls: SitemapEntry[] = [];
|
const productUrls: SitemapEntry[] = [];
|
||||||
|
|
||||||
for (const product of products) {
|
for (const product of filteredProducts) {
|
||||||
const hreflangs: Record<string, string> = {};
|
const hreflangs: Record<string, string> = {};
|
||||||
for (const locale of SUPPORTED_LOCALES) {
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
const path = locale === "sr" ? `/products/${product.slug}` : `/${locale}/products/${product.slug}`;
|
const path = locale === "sr" ? `/products/${product.slug}` : `/${locale}/products/${product.slug}`;
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function NewsletterSection() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder={t("emailPlaceholder")}
|
placeholder={t("emailPlaceholder")}
|
||||||
required
|
required
|
||||||
className="flex-1 px-4 py-4 h-12 border border-[#1A1A1A]/10 rounded-[4px] text-sm focus:outline-none focus:border-[#1A1A1A]/30 transition-colors"
|
className="flex-1 px-4 py-4 h-14 border border-[#1A1A1A]/10 rounded-[4px] text-base focus:outline-none focus:border-[#1A1A1A]/30 transition-colors"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
163
src/components/product/BundleSelector.tsx
Normal file
163
src/components/product/BundleSelector.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import type { Product } from "@/types/saleor";
|
||||||
|
import { getProductPrice, formatPrice } from "@/lib/saleor";
|
||||||
|
|
||||||
|
interface BundleSelectorProps {
|
||||||
|
baseProduct: Product;
|
||||||
|
bundleProducts: Product[];
|
||||||
|
selectedVariantId: string | null;
|
||||||
|
onSelectVariant: (variantId: string, quantity: number, price: number) => void;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BundleOption {
|
||||||
|
product: Product;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
pricePerUnit: number;
|
||||||
|
savings: number;
|
||||||
|
isBase: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BundleSelector({
|
||||||
|
baseProduct,
|
||||||
|
bundleProducts,
|
||||||
|
selectedVariantId,
|
||||||
|
onSelectVariant,
|
||||||
|
locale,
|
||||||
|
}: BundleSelectorProps) {
|
||||||
|
const t = useTranslations("Bundle");
|
||||||
|
|
||||||
|
if (bundleProducts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseVariant = baseProduct.variants?.[0];
|
||||||
|
const basePrice = baseVariant?.pricing?.price?.gross?.amount || 0;
|
||||||
|
|
||||||
|
const options: BundleOption[] = [];
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
product: baseProduct,
|
||||||
|
quantity: 1,
|
||||||
|
price: basePrice,
|
||||||
|
pricePerUnit: basePrice,
|
||||||
|
savings: 0,
|
||||||
|
isBase: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
bundleProducts.forEach((bundle) => {
|
||||||
|
const variant = bundle.variants?.[0];
|
||||||
|
if (!variant?.pricing?.price?.gross?.amount) return;
|
||||||
|
|
||||||
|
const price = variant.pricing.price.gross.amount;
|
||||||
|
const quantityMatch = bundle.name.match(/(\d+)x/i);
|
||||||
|
const quantity = quantityMatch ? parseInt(quantityMatch[1], 10) : 1;
|
||||||
|
const pricePerUnit = price / quantity;
|
||||||
|
const savings = (basePrice * quantity) - price;
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
product: bundle,
|
||||||
|
quantity,
|
||||||
|
price,
|
||||||
|
pricePerUnit,
|
||||||
|
savings,
|
||||||
|
isBase: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
options.sort((a, b) => a.quantity - b.quantity);
|
||||||
|
|
||||||
|
const formatPriceWithLocale = (amount: number, currency: string = "RSD") => {
|
||||||
|
const localeMap: Record<string, string> = {
|
||||||
|
sr: "sr-RS",
|
||||||
|
en: "en-US",
|
||||||
|
de: "de-DE",
|
||||||
|
fr: "fr-FR",
|
||||||
|
};
|
||||||
|
const numLocale = localeMap[locale] || "sr-RS";
|
||||||
|
return new Intl.NumberFormat(numLocale, {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm uppercase tracking-[0.1em] font-medium">
|
||||||
|
{t("selectBundle")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{options.map((option) => {
|
||||||
|
const variantId = option.isBase
|
||||||
|
? baseVariant?.id
|
||||||
|
: option.product.variants?.[0]?.id;
|
||||||
|
const isSelected = selectedVariantId === variantId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={option.product.id}
|
||||||
|
onClick={() => variantId && onSelectVariant(variantId, option.quantity, option.price)}
|
||||||
|
className={`w-full p-4 border-2 transition-all text-left ${
|
||||||
|
isSelected
|
||||||
|
? "border-black bg-black text-white"
|
||||||
|
: "border-[#e5e5e5] hover:border-[#999999]"
|
||||||
|
}`}
|
||||||
|
whileHover={{ scale: 1.01 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||||
|
isSelected
|
||||||
|
? "border-white bg-white"
|
||||||
|
: "border-[#999999]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
className="w-2.5 h-2.5 rounded-full bg-black"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">
|
||||||
|
{option.isBase ? t("singleUnit") : t("xSet", { count: option.quantity })}
|
||||||
|
</span>
|
||||||
|
{!option.isBase && option.savings > 0 && (
|
||||||
|
<span className="ml-2 text-xs text-green-500">
|
||||||
|
{t("save", { amount: formatPriceWithLocale(option.savings) })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
<div className={`font-bold ${isSelected ? "text-white" : "text-black"}`}>
|
||||||
|
{formatPriceWithLocale(option.price)}
|
||||||
|
</div>
|
||||||
|
{!option.isBase && (
|
||||||
|
<div className={`text-xs ${isSelected ? "text-white/70" : "text-[#666666]"}`}>
|
||||||
|
{formatPriceWithLocale(option.pricePerUnit)} {t("perUnit")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,10 +19,12 @@ import TrustBadges from "@/components/home/TrustBadges";
|
|||||||
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
|
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";
|
||||||
|
|
||||||
interface ProductDetailProps {
|
interface ProductDetailProps {
|
||||||
product: Product;
|
product: Product;
|
||||||
relatedProducts: Product[];
|
relatedProducts: Product[];
|
||||||
|
bundleProducts?: Product[];
|
||||||
locale?: string;
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +90,14 @@ function StarRating({ rating = 5, count = 0 }: { rating?: number; count?: number
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductDetail({ product, relatedProducts, locale = "sr" }: ProductDetailProps) {
|
export default function ProductDetail({ product, relatedProducts, bundleProducts = [], locale = "sr" }: ProductDetailProps) {
|
||||||
const t = useTranslations("ProductDetail");
|
const t = useTranslations("ProductDetail");
|
||||||
const tProduct = useTranslations("Product");
|
const tProduct = useTranslations("Product");
|
||||||
const [selectedImage, setSelectedImage] = useState(0);
|
const [selectedImage, setSelectedImage] = useState(0);
|
||||||
const [quantity, setQuantity] = useState(1);
|
const [quantity, setQuantity] = useState(1);
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [urgencyIndex, setUrgencyIndex] = useState(0);
|
const [urgencyIndex, setUrgencyIndex] = useState(0);
|
||||||
|
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
|
||||||
const { addLine, openCart } = useSaleorCheckoutStore();
|
const { addLine, openCart } = useSaleorCheckoutStore();
|
||||||
const validLocale = isValidLocale(locale) ? locale : "sr";
|
const validLocale = isValidLocale(locale) ? locale : "sr";
|
||||||
|
|
||||||
@@ -112,28 +115,53 @@ export default function ProductDetail({ product, relatedProducts, locale = "sr"
|
|||||||
];
|
];
|
||||||
|
|
||||||
const localized = getLocalizedProduct(product, locale);
|
const localized = getLocalizedProduct(product, locale);
|
||||||
const variant = product.variants?.[0];
|
const baseVariant = product.variants?.[0];
|
||||||
|
const selectedVariantId = selectedBundleVariantId || baseVariant?.id;
|
||||||
|
|
||||||
|
const selectedVariant = selectedVariantId === baseVariant?.id
|
||||||
|
? baseVariant
|
||||||
|
: bundleProducts.find(p => p.variants?.[0]?.id === selectedVariantId)?.variants?.[0];
|
||||||
|
|
||||||
const images = product.media?.length > 0
|
const images = product.media?.length > 0
|
||||||
? product.media.filter(m => m.type === "IMAGE")
|
? product.media.filter(m => m.type === "IMAGE")
|
||||||
: [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" as const }];
|
: [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" as const }];
|
||||||
|
|
||||||
const handleAddToCart = async () => {
|
const handleAddToCart = async () => {
|
||||||
if (!variant?.id) return;
|
if (!selectedVariantId) return;
|
||||||
|
|
||||||
setIsAdding(true);
|
setIsAdding(true);
|
||||||
try {
|
try {
|
||||||
await addLine(variant.id, quantity);
|
await addLine(selectedVariantId, 1);
|
||||||
openCart();
|
openCart();
|
||||||
} finally {
|
} finally {
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAvailable = variant?.quantityAvailable > 0;
|
const handleSelectVariant = (variantId: string, qty: number, price: number) => {
|
||||||
const price = getProductPrice(product);
|
setSelectedBundleVariantId(variantId);
|
||||||
const priceAmount = getProductPriceAmount(product);
|
setQuantity(qty);
|
||||||
const originalPrice = priceAmount > 0 ? formatPrice(Math.round(priceAmount * 1.30)) : null;
|
};
|
||||||
|
|
||||||
|
const isAvailable = (selectedVariant?.quantityAvailable ?? 0) > 0;
|
||||||
|
|
||||||
|
const selectedPrice = selectedVariant?.pricing?.price?.gross?.amount || 0;
|
||||||
|
const price = selectedPrice > 0
|
||||||
|
? new Intl.NumberFormat(validLocale === "en" ? "en-US" : validLocale === "de" ? "de-DE" : validLocale === "fr" ? "fr-FR" : "sr-RS", {
|
||||||
|
style: "currency",
|
||||||
|
currency: selectedVariant?.pricing?.price?.gross?.currency || "RSD",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(selectedPrice)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const priceAmount = selectedPrice;
|
||||||
|
const originalPrice = priceAmount > 0 ? new Intl.NumberFormat(validLocale === "en" ? "en-US" : validLocale === "de" ? "de-DE" : validLocale === "fr" ? "fr-FR" : "sr-RS", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RSD",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(Math.round(priceAmount * 1.30)) : null;
|
||||||
|
|
||||||
const shortDescription = getTranslatedShortDescription(localized.description, validLocale);
|
const shortDescription = getTranslatedShortDescription(localized.description, validLocale);
|
||||||
|
|
||||||
@@ -292,52 +320,40 @@ export default function ProductDetail({ product, relatedProducts, locale = "sr"
|
|||||||
|
|
||||||
<div className="border-t border-[#e5e5e5] mb-8" />
|
<div className="border-t border-[#e5e5e5] mb-8" />
|
||||||
|
|
||||||
{product.variants && product.variants.length > 1 && (
|
{bundleProducts.length > 0 ? (
|
||||||
<div className="mb-8">
|
<BundleSelector
|
||||||
<div className="flex items-center justify-between mb-4">
|
baseProduct={product}
|
||||||
<span className="text-sm uppercase tracking-[0.1em] font-medium">
|
bundleProducts={bundleProducts}
|
||||||
{t("size")}
|
selectedVariantId={selectedBundleVariantId || baseVariant?.id || null}
|
||||||
</span>
|
onSelectVariant={handleSelectVariant}
|
||||||
|
locale={validLocale}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
product.variants && product.variants.length > 1 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm uppercase tracking-[0.1em] font-medium">
|
||||||
|
{t("size")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{product.variants.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
className={`px-5 py-3 text-sm border-2 transition-colors ${
|
||||||
|
v.id === baseVariant?.id
|
||||||
|
? "border-black bg-black text-white"
|
||||||
|
: "border-[#e5e5e5] hover:border-[#999999]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{v.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
)
|
||||||
{product.variants.map((v) => (
|
|
||||||
<button
|
|
||||||
key={v.id}
|
|
||||||
className={`px-5 py-3 text-sm border-2 transition-colors ${
|
|
||||||
v.id === variant?.id
|
|
||||||
? "border-black bg-black text-white"
|
|
||||||
: "border-[#e5e5e5] hover:border-[#999999]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{v.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mb-8">
|
|
||||||
<span className="text-sm uppercase tracking-[0.1em] font-medium w-16">
|
|
||||||
{t("qty")}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center border-2 border-[#1a1a1a]">
|
|
||||||
<button
|
|
||||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
|
||||||
className="w-12 h-12 flex items-center justify-center hover:bg-[#f8f9fa] transition-colors"
|
|
||||||
disabled={quantity <= 1}
|
|
||||||
>
|
|
||||||
<Minus className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<span className="w-14 text-center text-base font-medium">{quantity}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setQuantity(quantity + 1)}
|
|
||||||
className="w-12 h-12 flex items-center justify-center hover:bg-[#f8f9fa] transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isAvailable ? (
|
{isAvailable ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleAddToCart}
|
onClick={handleAddToCart}
|
||||||
@@ -425,9 +441,9 @@ export default function ProductDetail({ product, relatedProducts, locale = "sr"
|
|||||||
</ExpandableSection>
|
</ExpandableSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{variant?.sku && (
|
{selectedVariant?.sku && (
|
||||||
<p className="text-xs text-[#999999] mt-8">
|
<p className="text-xs text-[#999999] mt-8">
|
||||||
SKU: {variant.sku}
|
SKU: {selectedVariant.sku}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
98
src/emails/BaseLayout.tsx
Normal file
98
src/emails/BaseLayout.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Hr,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Link,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
} from "@react-email/components";
|
||||||
|
|
||||||
|
interface BaseLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
previewText: string;
|
||||||
|
language: string;
|
||||||
|
siteUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const translations: Record<string, { footer: string; company: string }> = {
|
||||||
|
sr: {
|
||||||
|
footer: "ManoonOils - Prirodna kozmetika | www.manoonoils.com",
|
||||||
|
company: "ManoonOils",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
footer: "ManoonOils - Natural Cosmetics | www.manoonoils.com",
|
||||||
|
company: "ManoonOils",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
footer: "ManoonOils - Natürliche Kosmetik | www.manoonoils.com",
|
||||||
|
company: "ManoonOils",
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
footer: "ManoonOils - Cosmétiques Naturels | www.manoonoils.com",
|
||||||
|
company: "ManoonOils",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BaseLayout({ children, previewText, language, siteUrl }: BaseLayoutProps) {
|
||||||
|
const t = translations[language] || translations.en;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head />
|
||||||
|
<Preview>{previewText}</Preview>
|
||||||
|
<Body style={styles.body}>
|
||||||
|
<Container style={styles.container}>
|
||||||
|
<Section style={styles.logoSection}>
|
||||||
|
<Img
|
||||||
|
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
||||||
|
width="150"
|
||||||
|
height="auto"
|
||||||
|
alt="ManoonOils"
|
||||||
|
style={styles.logo}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
{children}
|
||||||
|
<Section style={styles.footer}>
|
||||||
|
<Text style={styles.footerText}>{t.footer}</Text>
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
body: {
|
||||||
|
backgroundColor: "#f6f6f6",
|
||||||
|
fontFamily:
|
||||||
|
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
margin: "0 auto",
|
||||||
|
padding: "40px 20px",
|
||||||
|
maxWidth: "600px",
|
||||||
|
},
|
||||||
|
logoSection: {
|
||||||
|
textAlign: "center" as const,
|
||||||
|
marginBottom: "30px",
|
||||||
|
},
|
||||||
|
logo: {
|
||||||
|
margin: "0 auto",
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
marginTop: "40px",
|
||||||
|
paddingTop: "20px",
|
||||||
|
borderTop: "1px solid #e0e0e0",
|
||||||
|
},
|
||||||
|
footerText: {
|
||||||
|
color: "#666666",
|
||||||
|
fontSize: "12px",
|
||||||
|
textAlign: "center" as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
237
src/emails/OrderCancelled.tsx
Normal file
237
src/emails/OrderCancelled.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { Button, Hr, Section, Text } from "@react-email/components";
|
||||||
|
import { BaseLayout } from "./BaseLayout";
|
||||||
|
|
||||||
|
interface OrderItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
price: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderCancelledProps {
|
||||||
|
language: string;
|
||||||
|
orderId: string;
|
||||||
|
orderNumber: string;
|
||||||
|
customerName: string;
|
||||||
|
items: OrderItem[];
|
||||||
|
total: string;
|
||||||
|
reason?: string;
|
||||||
|
siteUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const translations: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
preview: string;
|
||||||
|
greeting: string;
|
||||||
|
orderCancelled: string;
|
||||||
|
items: string;
|
||||||
|
total: string;
|
||||||
|
reason: string;
|
||||||
|
questions: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
sr: {
|
||||||
|
title: "Vaša narudžbina je otkazana",
|
||||||
|
preview: "Vaša narudžbina je otkazana",
|
||||||
|
greeting: "Poštovani {name},",
|
||||||
|
orderCancelled:
|
||||||
|
"Vaša narudžbina je otkazana. Ako niste zatražili otkazivanje, molimo kontaktirajte nas što pre.",
|
||||||
|
items: "Artikli",
|
||||||
|
total: "Ukupno",
|
||||||
|
reason: "Razlog",
|
||||||
|
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "Your Order Has Been Cancelled",
|
||||||
|
preview: "Your order has been cancelled",
|
||||||
|
greeting: "Dear {name},",
|
||||||
|
orderCancelled:
|
||||||
|
"Your order has been cancelled. If you did not request this cancellation, please contact us as soon as possible.",
|
||||||
|
items: "Items",
|
||||||
|
total: "Total",
|
||||||
|
reason: "Reason",
|
||||||
|
questions: "Questions? Email us at support@manoonoils.com",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
title: "Ihre Bestellung wurde storniert",
|
||||||
|
preview: "Ihre Bestellung wurde storniert",
|
||||||
|
greeting: "Sehr geehrte/r {name},",
|
||||||
|
orderCancelled:
|
||||||
|
"Ihre Bestellung wurde storniert. Wenn Sie diese Stornierung nicht angefordert haben, kontaktieren Sie uns bitte so schnell wie möglich.",
|
||||||
|
items: "Artikel",
|
||||||
|
total: "Gesamt",
|
||||||
|
reason: "Grund",
|
||||||
|
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
title: "Votre commande a été annulée",
|
||||||
|
preview: "Votre commande a été annulée",
|
||||||
|
greeting: "Cher(e) {name},",
|
||||||
|
orderCancelled:
|
||||||
|
"Votre commande a été annulée. Si vous n'avez pas demandé cette annulation, veuillez nous contacter dès que possible.",
|
||||||
|
items: "Articles",
|
||||||
|
total: "Total",
|
||||||
|
reason: "Raison",
|
||||||
|
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrderCancelled({
|
||||||
|
language = "en",
|
||||||
|
orderId,
|
||||||
|
orderNumber,
|
||||||
|
customerName,
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
reason,
|
||||||
|
siteUrl,
|
||||||
|
}: OrderCancelledProps) {
|
||||||
|
const t = translations[language] || translations.en;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||||
|
<Text style={styles.title}>{t.title}</Text>
|
||||||
|
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||||
|
<Text style={styles.text}>{t.orderCancelled}</Text>
|
||||||
|
|
||||||
|
<Section style={styles.orderInfo}>
|
||||||
|
<Text style={styles.orderNumber}>
|
||||||
|
<strong>Order Number:</strong> {orderNumber}
|
||||||
|
</Text>
|
||||||
|
{reason && (
|
||||||
|
<Text style={styles.reason}>
|
||||||
|
<strong>{t.reason}:</strong> {reason}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={styles.itemsSection}>
|
||||||
|
<Text style={styles.sectionTitle}>{t.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}>{t.total}:</Text>
|
||||||
|
<Text style={styles.totalValue}>{total}</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={styles.buttonSection}>
|
||||||
|
<Button href={siteUrl} style={styles.button}>
|
||||||
|
{language === "sr" ? "Pogledajte proizvode" : "Browse Products"}
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text style={styles.questions}>{t.questions}</Text>
|
||||||
|
</BaseLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
title: {
|
||||||
|
fontSize: "24px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#dc2626",
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
greeting: {
|
||||||
|
fontSize: "16px",
|
||||||
|
color: "#333333",
|
||||||
|
marginBottom: "10px",
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666666",
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
orderInfo: {
|
||||||
|
backgroundColor: "#fef2f2",
|
||||||
|
padding: "15px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
orderNumber: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#333333",
|
||||||
|
margin: "0 0 5px 0",
|
||||||
|
},
|
||||||
|
reason: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#991b1b",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
itemsSection: {
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
marginBottom: "10px",
|
||||||
|
},
|
||||||
|
hr: {
|
||||||
|
borderColor: "#e0e0e0",
|
||||||
|
margin: "10px 0",
|
||||||
|
},
|
||||||
|
itemRow: {
|
||||||
|
display: "flex" as const,
|
||||||
|
justifyContent: "space-between" as const,
|
||||||
|
padding: "8px 0",
|
||||||
|
},
|
||||||
|
itemName: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666666",
|
||||||
|
margin: "0",
|
||||||
|
textDecoration: "line-through",
|
||||||
|
},
|
||||||
|
itemPrice: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666666",
|
||||||
|
margin: "0",
|
||||||
|
textDecoration: "line-through",
|
||||||
|
},
|
||||||
|
totalRow: {
|
||||||
|
display: "flex" as const,
|
||||||
|
justifyContent: "space-between" as const,
|
||||||
|
padding: "8px 0",
|
||||||
|
},
|
||||||
|
totalLabel: {
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#666666",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
totalValue: {
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#666666",
|
||||||
|
margin: "0",
|
||||||
|
textDecoration: "line-through",
|
||||||
|
},
|
||||||
|
buttonSection: {
|
||||||
|
textAlign: "center" as const,
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
color: "#ffffff",
|
||||||
|
padding: "12px 30px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
textDecoration: "none",
|
||||||
|
},
|
||||||
|
questions: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666666",
|
||||||
|
},
|
||||||
|
};
|
||||||
394
src/emails/OrderConfirmation.tsx
Normal file
394
src/emails/OrderConfirmation.tsx
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
import { Button, Hr, Section, Text } from "@react-email/components";
|
||||||
|
import { BaseLayout } from "./BaseLayout";
|
||||||
|
|
||||||
|
interface OrderItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
price: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderConfirmationProps {
|
||||||
|
language: string;
|
||||||
|
orderId: string;
|
||||||
|
orderNumber: string;
|
||||||
|
customerEmail: string;
|
||||||
|
customerName: string;
|
||||||
|
items: OrderItem[];
|
||||||
|
total: string;
|
||||||
|
shippingAddress?: string;
|
||||||
|
billingAddress?: string;
|
||||||
|
phone?: string;
|
||||||
|
siteUrl: string;
|
||||||
|
dashboardUrl?: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const translations: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
preview: string;
|
||||||
|
greeting: string;
|
||||||
|
orderReceived: string;
|
||||||
|
orderNumber: string;
|
||||||
|
items: string;
|
||||||
|
quantity: string;
|
||||||
|
total: string;
|
||||||
|
shippingTo: string;
|
||||||
|
questions: string;
|
||||||
|
thankYou: string;
|
||||||
|
adminTitle: string;
|
||||||
|
adminPreview: string;
|
||||||
|
adminGreeting: string;
|
||||||
|
adminMessage: string;
|
||||||
|
customerLabel: string;
|
||||||
|
customerEmailLabel: string;
|
||||||
|
billingAddressLabel: string;
|
||||||
|
phoneLabel: string;
|
||||||
|
viewDashboard: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
sr: {
|
||||||
|
title: "Potvrda narudžbine",
|
||||||
|
preview: "Vaša narudžbina je potvrđena",
|
||||||
|
greeting: "Poštovani {name},",
|
||||||
|
orderReceived: "Zahvaljujemo se na Vašoj narudžbini! Primili smo je i sada je u pripremi.",
|
||||||
|
orderNumber: "Broj narudžbine",
|
||||||
|
items: "Artikli",
|
||||||
|
quantity: "Količina",
|
||||||
|
total: "Ukupno",
|
||||||
|
shippingTo: "Adresa za dostavu",
|
||||||
|
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||||
|
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: {
|
||||||
|
title: "Order Confirmation",
|
||||||
|
preview: "Your order has been confirmed",
|
||||||
|
greeting: "Dear {name},",
|
||||||
|
orderReceived:
|
||||||
|
"Thank you for your order! We have received it and it is now being processed.",
|
||||||
|
orderNumber: "Order number",
|
||||||
|
items: "Items",
|
||||||
|
quantity: "Quantity",
|
||||||
|
total: "Total",
|
||||||
|
shippingTo: "Shipping address",
|
||||||
|
questions: "Questions? Email us at support@manoonoils.com",
|
||||||
|
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: {
|
||||||
|
title: "Bestellungsbestätigung",
|
||||||
|
preview: "Ihre Bestellung wurde bestätigt",
|
||||||
|
greeting: "Sehr geehrte/r {name},",
|
||||||
|
orderReceived:
|
||||||
|
"Vielen Dank für Ihre Bestellung! Wir haben sie erhalten und sie wird nun bearbeitet.",
|
||||||
|
orderNumber: "Bestellnummer",
|
||||||
|
items: "Artikel",
|
||||||
|
quantity: "Menge",
|
||||||
|
total: "Gesamt",
|
||||||
|
shippingTo: "Lieferadresse",
|
||||||
|
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||||
|
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: {
|
||||||
|
title: "Confirmation de commande",
|
||||||
|
preview: "Votre commande a été confirmée",
|
||||||
|
greeting: "Cher(e) {name},",
|
||||||
|
orderReceived:
|
||||||
|
"Merci pour votre commande! Nous l'avons reçue et elle est en cours de traitement.",
|
||||||
|
orderNumber: "Numéro de commande",
|
||||||
|
items: "Articles",
|
||||||
|
quantity: "Quantité",
|
||||||
|
total: "Total",
|
||||||
|
shippingTo: "Adresse de livraison",
|
||||||
|
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrderConfirmation({
|
||||||
|
language = "en",
|
||||||
|
orderId,
|
||||||
|
orderNumber,
|
||||||
|
customerEmail,
|
||||||
|
customerName,
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
shippingAddress,
|
||||||
|
billingAddress,
|
||||||
|
phone,
|
||||||
|
siteUrl,
|
||||||
|
dashboardUrl,
|
||||||
|
isAdmin = false,
|
||||||
|
}: OrderConfirmationProps) {
|
||||||
|
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 (
|
||||||
|
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||||
|
<Text style={styles.title}>{t.title}</Text>
|
||||||
|
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||||
|
<Text style={styles.text}>{t.orderReceived}</Text>
|
||||||
|
|
||||||
|
<Section style={styles.orderInfo}>
|
||||||
|
<Text style={styles.orderNumber}>
|
||||||
|
<strong>{t.orderNumber}:</strong> {orderNumber}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={styles.itemsSection}>
|
||||||
|
<Text style={styles.sectionTitle}>{t.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}>{t.total}:</Text>
|
||||||
|
<Text style={styles.totalValue}>{total}</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{shippingAddress && (
|
||||||
|
<Section style={styles.shippingSection}>
|
||||||
|
<Text style={styles.sectionTitle}>{t.shippingTo}</Text>
|
||||||
|
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section style={styles.buttonSection}>
|
||||||
|
<Button href={siteUrl} style={styles.button}>
|
||||||
|
{language === "sr"
|
||||||
|
? "Pogledajte narudžbinu"
|
||||||
|
: language === "de"
|
||||||
|
? "Bestellung ansehen"
|
||||||
|
: language === "fr"
|
||||||
|
? "Voir la commande"
|
||||||
|
: "View Order"}
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text style={styles.questions}>{t.questions}</Text>
|
||||||
|
<Text style={styles.thankYou}>{t.thankYou}</Text>
|
||||||
|
</BaseLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
title: {
|
||||||
|
fontSize: "24px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
greeting: {
|
||||||
|
fontSize: "16px",
|
||||||
|
color: "#333333",
|
||||||
|
marginBottom: "10px",
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666666",
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
orderInfo: {
|
||||||
|
backgroundColor: "#f9f9f9",
|
||||||
|
padding: "15px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
orderNumber: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#333333",
|
||||||
|
margin: "0 0 8px 0",
|
||||||
|
},
|
||||||
|
customerInfo: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#333333",
|
||||||
|
margin: "0 0 4px 0",
|
||||||
|
},
|
||||||
|
itemsSection: {
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
marginBottom: "10px",
|
||||||
|
},
|
||||||
|
hr: {
|
||||||
|
borderColor: "#e0e0e0",
|
||||||
|
margin: "10px 0",
|
||||||
|
},
|
||||||
|
itemRow: {
|
||||||
|
display: "flex" as const,
|
||||||
|
justifyContent: "space-between" as const,
|
||||||
|
padding: "8px 0",
|
||||||
|
},
|
||||||
|
itemName: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#333333",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
itemPrice: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#333333",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
totalRow: {
|
||||||
|
display: "flex" as const,
|
||||||
|
justifyContent: "space-between" as const,
|
||||||
|
padding: "8px 0",
|
||||||
|
},
|
||||||
|
totalLabel: {
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
totalValue: {
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
shippingSection: {
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
shippingAddress: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666666",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
buttonSection: {
|
||||||
|
textAlign: "center" as const,
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
color: "#ffffff",
|
||||||
|
padding: "12px 30px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
textDecoration: "none",
|
||||||
|
},
|
||||||
|
questions: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666666",
|
||||||
|
marginBottom: "10px",
|
||||||
|
},
|
||||||
|
thankYou: {
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
},
|
||||||
|
};
|
||||||
253
src/emails/OrderPaid.tsx
Normal file
253
src/emails/OrderPaid.tsx
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { Button, Hr, Section, Text } from "@react-email/components";
|
||||||
|
import { BaseLayout } from "./BaseLayout";
|
||||||
|
|
||||||
|
interface OrderItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
price: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderPaidProps {
|
||||||
|
language: string;
|
||||||
|
orderId: string;
|
||||||
|
orderNumber: string;
|
||||||
|
customerName: string;
|
||||||
|
items: OrderItem[];
|
||||||
|
total: string;
|
||||||
|
siteUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const translations: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
preview: string;
|
||||||
|
greeting: string;
|
||||||
|
orderPaid: string;
|
||||||
|
items: string;
|
||||||
|
total: string;
|
||||||
|
nextSteps: string;
|
||||||
|
nextStepsText: string;
|
||||||
|
questions: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
sr: {
|
||||||
|
title: "Plaćanje je primljeno!",
|
||||||
|
preview: "Vaša uplata je zabeležena",
|
||||||
|
greeting: "Poštovani {name},",
|
||||||
|
orderPaid:
|
||||||
|
"Plaćanje za vašu narudžbinu je primljeno. Hvala vam! Narudžbina će uskoro biti spremna za slanje.",
|
||||||
|
items: "Artikli",
|
||||||
|
total: "Ukupno",
|
||||||
|
nextSteps: "Šta dalje?",
|
||||||
|
nextStepsText:
|
||||||
|
"Primićete još jedan email kada vaša narudžbina bude poslata. Možete očekivati dostavu u roku od 3-5 radnih dana.",
|
||||||
|
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "Payment Received!",
|
||||||
|
preview: "Your payment has been recorded",
|
||||||
|
greeting: "Dear {name},",
|
||||||
|
orderPaid:
|
||||||
|
"Payment for your order has been received. Thank you! Your order will be prepared for shipping soon.",
|
||||||
|
items: "Items",
|
||||||
|
total: "Total",
|
||||||
|
nextSteps: "What's next?",
|
||||||
|
nextStepsText:
|
||||||
|
"You will receive another email when your order ships. You can expect delivery within 3-5 business days.",
|
||||||
|
questions: "Questions? Email us at support@manoonoils.com",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
title: "Zahlung erhalten!",
|
||||||
|
preview: "Ihre Zahlung wurde verbucht",
|
||||||
|
greeting: "Sehr geehrte/r {name},",
|
||||||
|
orderPaid:
|
||||||
|
"Zahlung für Ihre Bestellung ist eingegangen. Vielen Dank! Ihre Bestellung wird bald für den Versand vorbereitet.",
|
||||||
|
items: "Artikel",
|
||||||
|
total: "Gesamt",
|
||||||
|
nextSteps: "Was kommt als nächstes?",
|
||||||
|
nextStepsText:
|
||||||
|
"Sie erhalten eine weitere E-Mail, wenn Ihre Bestellung versandt wird. Die Lieferung erfolgt innerhalb von 3-5 Werktagen.",
|
||||||
|
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
title: "Paiement reçu!",
|
||||||
|
preview: "Votre paiement a été enregistré",
|
||||||
|
greeting: "Cher(e) {name},",
|
||||||
|
orderPaid:
|
||||||
|
"Le paiement de votre commande a été reçu. Merci! Votre commande sera bientôt prête à être expédiée.",
|
||||||
|
items: "Articles",
|
||||||
|
total: "Total",
|
||||||
|
nextSteps: "Et ensuite?",
|
||||||
|
nextStepsText:
|
||||||
|
"Vous recevrez un autre email lorsque votre commande sera expédiée. Vous pouvez vous attendre à une livraison dans 3-5 jours ouvrables.",
|
||||||
|
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrderPaid({
|
||||||
|
language = "en",
|
||||||
|
orderId,
|
||||||
|
orderNumber,
|
||||||
|
customerName,
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
siteUrl,
|
||||||
|
}: OrderPaidProps) {
|
||||||
|
const t = translations[language] || translations.en;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||||
|
<Text style={styles.title}>{t.title}</Text>
|
||||||
|
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||||
|
<Text style={styles.text}>{t.orderPaid}</Text>
|
||||||
|
|
||||||
|
<Section style={styles.orderInfo}>
|
||||||
|
<Text style={styles.orderNumber}>
|
||||||
|
<strong>Order Number:</strong> {orderNumber}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={styles.itemsSection}>
|
||||||
|
<Text style={styles.sectionTitle}>{t.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}>{t.total}:</Text>
|
||||||
|
<Text style={styles.totalValue}>{total}</Text>
|
||||||
|
</Section>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={styles.nextSteps}>
|
||||||
|
<Text style={styles.nextStepsTitle}>{t.nextSteps}</Text>
|
||||||
|
<Text style={styles.nextStepsText}>{t.nextStepsText}</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section style={styles.buttonSection}>
|
||||||
|
<Button href={siteUrl} style={styles.button}>
|
||||||
|
{language === "sr" ? "Nastavite kupovinu" : "Continue Shopping"}
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text style={styles.questions}>{t.questions}</Text>
|
||||||
|
</BaseLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
title: {
|
||||||
|
fontSize: "24px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#16a34a",
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
greeting: {
|
||||||
|
fontSize: "16px",
|
||||||
|
color: "#333333",
|
||||||
|
marginBottom: "10px",
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666666",
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
orderInfo: {
|
||||||
|
backgroundColor: "#f0fdf4",
|
||||||
|
padding: "15px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
orderNumber: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#333333",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
itemsSection: {
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
marginBottom: "10px",
|
||||||
|
},
|
||||||
|
hr: {
|
||||||
|
borderColor: "#e0e0e0",
|
||||||
|
margin: "10px 0",
|
||||||
|
},
|
||||||
|
itemRow: {
|
||||||
|
display: "flex" as const,
|
||||||
|
justifyContent: "space-between" as const,
|
||||||
|
padding: "8px 0",
|
||||||
|
},
|
||||||
|
itemName: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#333333",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
itemPrice: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#333333",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
totalRow: {
|
||||||
|
display: "flex" as const,
|
||||||
|
justifyContent: "space-between" as const,
|
||||||
|
padding: "8px 0",
|
||||||
|
},
|
||||||
|
totalLabel: {
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
totalValue: {
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
nextSteps: {
|
||||||
|
backgroundColor: "#f9f9f9",
|
||||||
|
padding: "15px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
nextStepsTitle: {
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
marginBottom: "5px",
|
||||||
|
},
|
||||||
|
nextStepsText: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666666",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
buttonSection: {
|
||||||
|
textAlign: "center" as const,
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
color: "#ffffff",
|
||||||
|
padding: "12px 30px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
textDecoration: "none",
|
||||||
|
},
|
||||||
|
questions: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666666",
|
||||||
|
},
|
||||||
|
};
|
||||||
193
src/emails/OrderShipped.tsx
Normal file
193
src/emails/OrderShipped.tsx
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { Button, Hr, Section, Text } from "@react-email/components";
|
||||||
|
import { BaseLayout } from "./BaseLayout";
|
||||||
|
|
||||||
|
interface OrderItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
price: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderShippedProps {
|
||||||
|
language: string;
|
||||||
|
orderId: string;
|
||||||
|
orderNumber: string;
|
||||||
|
customerName: string;
|
||||||
|
items: OrderItem[];
|
||||||
|
trackingNumber?: string;
|
||||||
|
trackingUrl?: string;
|
||||||
|
siteUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const translations: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
preview: string;
|
||||||
|
greeting: string;
|
||||||
|
orderShipped: string;
|
||||||
|
tracking: string;
|
||||||
|
items: string;
|
||||||
|
questions: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
sr: {
|
||||||
|
title: "Vaša narudžbina je poslata!",
|
||||||
|
preview: "Vaša narudžbina je na putu",
|
||||||
|
greeting: "Poštovani {name},",
|
||||||
|
orderShipped:
|
||||||
|
"Odlične vesti! Vaša narudžbina je poslata i uskoro će stići na vašu adresu.",
|
||||||
|
tracking: "Praćenje pošiljke",
|
||||||
|
items: "Artikli",
|
||||||
|
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: "Your Order Has Shipped!",
|
||||||
|
preview: "Your order is on its way",
|
||||||
|
greeting: "Dear {name},",
|
||||||
|
orderShipped:
|
||||||
|
"Great news! Your order has been shipped and will arrive at your address soon.",
|
||||||
|
tracking: "Track your shipment",
|
||||||
|
items: "Items",
|
||||||
|
questions: "Questions? Email us at support@manoonoils.com",
|
||||||
|
},
|
||||||
|
de: {
|
||||||
|
title: "Ihre Bestellung wurde versendet!",
|
||||||
|
preview: "Ihre Bestellung ist unterwegs",
|
||||||
|
greeting: "Sehr geehrte/r {name},",
|
||||||
|
orderShipped:
|
||||||
|
"Großartige Neuigkeiten! Ihre Bestellung wurde versandt und wird in Kürze bei Ihnen eintreffen.",
|
||||||
|
tracking: "Sendung verfolgen",
|
||||||
|
items: "Artikel",
|
||||||
|
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||||
|
},
|
||||||
|
fr: {
|
||||||
|
title: "Votre commande a été expédiée!",
|
||||||
|
preview: "Votre commande est en route",
|
||||||
|
greeting: "Cher(e) {name},",
|
||||||
|
orderShipped:
|
||||||
|
"Bonne nouvelle! Votre commande a été expédiée et arrivera bientôt à votre adresse.",
|
||||||
|
tracking: "Suivre votre envoi",
|
||||||
|
items: "Articles",
|
||||||
|
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function OrderShipped({
|
||||||
|
language = "en",
|
||||||
|
orderId,
|
||||||
|
orderNumber,
|
||||||
|
customerName,
|
||||||
|
items,
|
||||||
|
trackingNumber,
|
||||||
|
trackingUrl,
|
||||||
|
siteUrl,
|
||||||
|
}: OrderShippedProps) {
|
||||||
|
const t = translations[language] || translations.en;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||||
|
<Text style={styles.title}>{t.title}</Text>
|
||||||
|
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||||
|
<Text style={styles.text}>{t.orderShipped}</Text>
|
||||||
|
|
||||||
|
{trackingNumber && (
|
||||||
|
<Section style={styles.trackingSection}>
|
||||||
|
<Text style={styles.sectionTitle}>{t.tracking}</Text>
|
||||||
|
{trackingUrl ? (
|
||||||
|
<Button href={trackingUrl} style={styles.trackingButton}>
|
||||||
|
{trackingNumber}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.trackingNumber}>{trackingNumber}</Text>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Section style={styles.itemsSection}>
|
||||||
|
<Text style={styles.sectionTitle}>{t.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>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Text style={styles.questions}>{t.questions}</Text>
|
||||||
|
</BaseLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
title: {
|
||||||
|
fontSize: "24px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
greeting: {
|
||||||
|
fontSize: "16px",
|
||||||
|
color: "#333333",
|
||||||
|
marginBottom: "10px",
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666666",
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
trackingSection: {
|
||||||
|
backgroundColor: "#f9f9f9",
|
||||||
|
padding: "15px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: "16px",
|
||||||
|
fontWeight: "bold" as const,
|
||||||
|
color: "#1a1a1a",
|
||||||
|
marginBottom: "10px",
|
||||||
|
},
|
||||||
|
trackingNumber: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#333333",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
trackingButton: {
|
||||||
|
backgroundColor: "#000000",
|
||||||
|
color: "#ffffff",
|
||||||
|
padding: "10px 20px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
fontSize: "14px",
|
||||||
|
textDecoration: "none",
|
||||||
|
},
|
||||||
|
itemsSection: {
|
||||||
|
marginBottom: "20px",
|
||||||
|
},
|
||||||
|
hr: {
|
||||||
|
borderColor: "#e0e0e0",
|
||||||
|
margin: "10px 0",
|
||||||
|
},
|
||||||
|
itemRow: {
|
||||||
|
display: "flex" as const,
|
||||||
|
justifyContent: "space-between" as const,
|
||||||
|
padding: "8px 0",
|
||||||
|
},
|
||||||
|
itemName: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#333333",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
itemPrice: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#333333",
|
||||||
|
margin: "0",
|
||||||
|
},
|
||||||
|
questions: {
|
||||||
|
fontSize: "14px",
|
||||||
|
color: "#666666",
|
||||||
|
},
|
||||||
|
};
|
||||||
5
src/emails/index.ts
Normal file
5
src/emails/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { BaseLayout } from "./BaseLayout";
|
||||||
|
export { OrderConfirmation } from "./OrderConfirmation";
|
||||||
|
export { OrderShipped } from "./OrderShipped";
|
||||||
|
export { OrderCancelled } from "./OrderCancelled";
|
||||||
|
export { OrderPaid } from "./OrderPaid";
|
||||||
@@ -312,6 +312,13 @@
|
|||||||
"urgency2": "In den Warenkörben von 2,5K Menschen - kaufen Sie, bevor es weg ist!",
|
"urgency2": "In den Warenkörben von 2,5K Menschen - kaufen Sie, bevor es weg ist!",
|
||||||
"urgency3": "7.562 Personen haben sich dieses Produkt in den letzten 24 Stunden angesehen!"
|
"urgency3": "7.562 Personen haben sich dieses Produkt in den letzten 24 Stunden angesehen!"
|
||||||
},
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Paket wählen",
|
||||||
|
"singleUnit": "1 Stück",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Spare {amount}",
|
||||||
|
"perUnit": "pro Stück"
|
||||||
|
},
|
||||||
"Newsletter": {
|
"Newsletter": {
|
||||||
"stayConnected": "Bleiben Sie verbunden",
|
"stayConnected": "Bleiben Sie verbunden",
|
||||||
"joinCommunity": "Werden Sie Teil unserer Gemeinschaft",
|
"joinCommunity": "Werden Sie Teil unserer Gemeinschaft",
|
||||||
@@ -333,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",
|
||||||
@@ -357,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!",
|
||||||
|
|||||||
@@ -341,6 +341,13 @@
|
|||||||
"urgency2": "In the carts of 2.5K people - buy before its gone!",
|
"urgency2": "In the carts of 2.5K people - buy before its gone!",
|
||||||
"urgency3": "7,562 people viewed this product in the last 24 hours!"
|
"urgency3": "7,562 people viewed this product in the last 24 hours!"
|
||||||
},
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Select Package",
|
||||||
|
"singleUnit": "1 Unit",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Save {amount}",
|
||||||
|
"perUnit": "per unit"
|
||||||
|
},
|
||||||
"Newsletter": {
|
"Newsletter": {
|
||||||
"stayConnected": "Stay Connected",
|
"stayConnected": "Stay Connected",
|
||||||
"joinCommunity": "Join Our Community",
|
"joinCommunity": "Join Our Community",
|
||||||
@@ -379,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",
|
||||||
@@ -403,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",
|
||||||
|
|||||||
@@ -312,6 +312,13 @@
|
|||||||
"urgency2": "Dans les paniers de 2,5K personnes - achetez avant qu'il ne disparaisse!",
|
"urgency2": "Dans les paniers de 2,5K personnes - achetez avant qu'il ne disparaisse!",
|
||||||
"urgency3": "7 562 personnes ont vu ce produit ces dernières 24 heures!"
|
"urgency3": "7 562 personnes ont vu ce produit ces dernières 24 heures!"
|
||||||
},
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Choisir le Pack",
|
||||||
|
"singleUnit": "1 Unité",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Économisez {amount}",
|
||||||
|
"perUnit": "par unité"
|
||||||
|
},
|
||||||
"Newsletter": {
|
"Newsletter": {
|
||||||
"stayConnected": "Restez Connectés",
|
"stayConnected": "Restez Connectés",
|
||||||
"joinCommunity": "Rejoignez Notre Communauté",
|
"joinCommunity": "Rejoignez Notre Communauté",
|
||||||
@@ -333,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",
|
||||||
@@ -357,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!",
|
||||||
|
|||||||
@@ -341,6 +341,13 @@
|
|||||||
"urgency2": "U korpama 2.5K ljudi - kupi pre nego što nestane!",
|
"urgency2": "U korpama 2.5K ljudi - kupi pre nego što nestane!",
|
||||||
"urgency3": "7.562 osobe su pogledale ovaj proizvod u poslednja 24 sata!"
|
"urgency3": "7.562 osobe su pogledale ovaj proizvod u poslednja 24 sata!"
|
||||||
},
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Izaberi pakovanje",
|
||||||
|
"singleUnit": "1 komad",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Štedi {amount}",
|
||||||
|
"perUnit": "po komadu"
|
||||||
|
},
|
||||||
"Newsletter": {
|
"Newsletter": {
|
||||||
"stayConnected": "Ostanite povezani",
|
"stayConnected": "Ostanite povezani",
|
||||||
"joinCommunity": "Pridružite se našoj zajednici",
|
"joinCommunity": "Pridružite se našoj zajednici",
|
||||||
@@ -379,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",
|
||||||
@@ -403,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!",
|
||||||
|
|||||||
106
src/lib/resend.ts
Normal file
106
src/lib/resend.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { Resend } from "resend";
|
||||||
|
import { render } from "@react-email/render";
|
||||||
|
|
||||||
|
let resendClient: Resend | null = null;
|
||||||
|
|
||||||
|
function getResendClient(): Resend {
|
||||||
|
if (!resendClient) {
|
||||||
|
if (!process.env.RESEND_API_KEY) {
|
||||||
|
throw new Error("RESEND_API_KEY environment variable is not set");
|
||||||
|
}
|
||||||
|
resendClient = new Resend(process.env.RESEND_API_KEY);
|
||||||
|
}
|
||||||
|
return resendClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADMIN_EMAILS = ["me@hytham.me", "tamara@hytham.me"];
|
||||||
|
|
||||||
|
export async function sendEmail({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
react,
|
||||||
|
text,
|
||||||
|
tags,
|
||||||
|
idempotencyKey,
|
||||||
|
}: {
|
||||||
|
to: string | string[];
|
||||||
|
subject: string;
|
||||||
|
react: React.ReactNode;
|
||||||
|
text?: string;
|
||||||
|
tags?: { name: string; value: string }[];
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}) {
|
||||||
|
const resend = getResendClient();
|
||||||
|
|
||||||
|
// Render React component to HTML
|
||||||
|
const html = await render(react, {
|
||||||
|
pretty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, error } = await resend.emails.send({
|
||||||
|
from: "ManoonOils <support@mail.manoonoils.com>",
|
||||||
|
replyTo: "support@manoonoils.com",
|
||||||
|
to: Array.isArray(to) ? to : [to],
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
tags,
|
||||||
|
...(idempotencyKey && { idempotencyKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Failed to send email:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmailToCustomer({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
react,
|
||||||
|
text,
|
||||||
|
language,
|
||||||
|
idempotencyKey,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
react: React.ReactNode;
|
||||||
|
text?: string;
|
||||||
|
language: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}) {
|
||||||
|
const tag = `customer-${language}`;
|
||||||
|
return sendEmail({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
react,
|
||||||
|
text,
|
||||||
|
tags: [{ name: "type", value: tag }],
|
||||||
|
idempotencyKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmailToAdmin({
|
||||||
|
subject,
|
||||||
|
react,
|
||||||
|
text,
|
||||||
|
eventType,
|
||||||
|
orderId,
|
||||||
|
}: {
|
||||||
|
subject: string;
|
||||||
|
react: React.ReactNode;
|
||||||
|
text?: string;
|
||||||
|
eventType: string;
|
||||||
|
orderId: string;
|
||||||
|
}) {
|
||||||
|
return sendEmail({
|
||||||
|
to: ADMIN_EMAILS,
|
||||||
|
subject: `[Admin] ${subject}`,
|
||||||
|
react,
|
||||||
|
text,
|
||||||
|
tags: [{ name: "type", value: "admin-notification" }],
|
||||||
|
idempotencyKey: `admin-${eventType}/${orderId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
81
src/lib/saleor/create-webhooks.graphql
Normal file
81
src/lib/saleor/create-webhooks.graphql
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Replace YOUR_STOREFRONT_URL with your actual storefront URL
|
||||||
|
# Dev: https://dev.manoonoils.com
|
||||||
|
# Prod: https://manoonoils.com
|
||||||
|
|
||||||
|
mutation CreateSaleorWebhooks {
|
||||||
|
orderConfirmedWebhook: webhookCreate(input: {
|
||||||
|
name: "Resend - Order Confirmed"
|
||||||
|
targetUrl: "YOUR_STOREFRONT_URL/api/webhooks/saleor"
|
||||||
|
events: [ORDER_CONFIRMED]
|
||||||
|
isActive: true
|
||||||
|
}) {
|
||||||
|
webhook {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
targetUrl
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orderPaidWebhook: webhookCreate(input: {
|
||||||
|
name: "Resend - Order Paid"
|
||||||
|
targetUrl: "YOUR_STOREFRONT_URL/api/webhooks/saleor"
|
||||||
|
events: [ORDER_FULLY_PAID]
|
||||||
|
isActive: true
|
||||||
|
}) {
|
||||||
|
webhook {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
targetUrl
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orderCancelledWebhook: webhookCreate(input: {
|
||||||
|
name: "Resend - Order Cancelled"
|
||||||
|
targetUrl: "YOUR_STOREFRONT_URL/api/webhooks/saleor"
|
||||||
|
events: [ORDER_CANCELLED]
|
||||||
|
isActive: true
|
||||||
|
}) {
|
||||||
|
webhook {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
targetUrl
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orderFulfilledWebhook: webhookCreate(input: {
|
||||||
|
name: "Resend - Order Fulfilled"
|
||||||
|
targetUrl: "YOUR_STOREFRONT_URL/api/webhooks/saleor"
|
||||||
|
events: [ORDER_FULFILLED]
|
||||||
|
isActive: true
|
||||||
|
}) {
|
||||||
|
webhook {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
targetUrl
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,18 @@ export const PRODUCT_FRAGMENT = gql`
|
|||||||
key
|
key
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
attributes {
|
||||||
|
attribute {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
values {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
${PRODUCT_VARIANT_FRAGMENT}
|
${PRODUCT_VARIANT_FRAGMENT}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export { PRODUCT_VARIANT_FRAGMENT, CHECKOUT_LINE_FRAGMENT } from "./fragments/Va
|
|||||||
export { CHECKOUT_FRAGMENT, ADDRESS_FRAGMENT } from "./fragments/Checkout";
|
export { CHECKOUT_FRAGMENT, ADDRESS_FRAGMENT } from "./fragments/Checkout";
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
export { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_PRODUCTS_BY_CATEGORY } from "./queries/Products";
|
export { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_PRODUCTS_BY_CATEGORY, GET_BUNDLE_PRODUCTS } from "./queries/Products";
|
||||||
export { GET_CHECKOUT, GET_CHECKOUT_BY_ID } from "./queries/Checkout";
|
export { GET_CHECKOUT, GET_CHECKOUT_BY_ID } from "./queries/Checkout";
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
@@ -34,4 +34,9 @@ export {
|
|||||||
formatPrice,
|
formatPrice,
|
||||||
getLocalizedProduct,
|
getLocalizedProduct,
|
||||||
parseDescription,
|
parseDescription,
|
||||||
|
getBundleProducts,
|
||||||
|
getBundleProductsForProduct,
|
||||||
|
getProductBundleComponents,
|
||||||
|
isBundleProduct,
|
||||||
|
filterOutBundles,
|
||||||
} from "./products";
|
} from "./products";
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { saleorClient } from "./client";
|
import { saleorClient } from "./client";
|
||||||
import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG } from "./queries/Products";
|
import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_BUNDLE_PRODUCTS } from "./queries/Products";
|
||||||
import type { Product } from "@/types/saleor";
|
import type { Product } from "@/types/saleor";
|
||||||
|
|
||||||
const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel";
|
const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel";
|
||||||
@@ -155,3 +155,69 @@ export function getLocalizedProduct(
|
|||||||
seoDescription: translation?.seoDescription || product.seoDescription,
|
seoDescription: translation?.seoDescription || product.seoDescription,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProductsResponse {
|
||||||
|
products?: {
|
||||||
|
edges: Array<{ node: Product }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBundleProducts(
|
||||||
|
locale: string = "SR",
|
||||||
|
first: number = 50
|
||||||
|
): Promise<Product[]> {
|
||||||
|
try {
|
||||||
|
const { data } = await saleorClient.query<ProductsResponse>({
|
||||||
|
query: GET_BUNDLE_PRODUCTS,
|
||||||
|
variables: {
|
||||||
|
channel: CHANNEL,
|
||||||
|
locale: locale.toUpperCase(),
|
||||||
|
first,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return data?.products?.edges.map((edge) => edge.node) || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching bundle products from Saleor:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBundleProductsForProduct(
|
||||||
|
allProducts: Product[],
|
||||||
|
baseProductId: string
|
||||||
|
): Product[] {
|
||||||
|
return allProducts.filter((product) => {
|
||||||
|
const bundleItemsAttr = product.attributes?.find(
|
||||||
|
(attr) => attr.attribute.slug === "bundle-items"
|
||||||
|
);
|
||||||
|
if (!bundleItemsAttr) return false;
|
||||||
|
return bundleItemsAttr.values.some((val) => {
|
||||||
|
const referencedId = Buffer.from(val.slug.split(":")[1] || val.id).toString("base64");
|
||||||
|
const expectedId = `UHJvZHVjdDo${baseProductId.split("UHJvZHVjdDo")[1]}`;
|
||||||
|
return referencedId.includes(baseProductId.split("UHJvZHVjdDo")[1] || "") ||
|
||||||
|
val.slug.includes(baseProductId.split("UHJvZHVjdDo")[1] || "");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProductBundleComponents(product: Product): number | null {
|
||||||
|
const bundleAttr = product.attributes?.find(
|
||||||
|
(attr) => attr.attribute.slug === "bundle-items"
|
||||||
|
);
|
||||||
|
if (!bundleAttr) return null;
|
||||||
|
|
||||||
|
const bundleAttrMatch = product.name.match(/(\d+)x/i);
|
||||||
|
if (bundleAttrMatch) {
|
||||||
|
return parseInt(bundleAttrMatch[1], 10);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBundleProduct(product: Product): boolean {
|
||||||
|
return getProductBundleComponents(product) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterOutBundles(products: Product[]): Product[] {
|
||||||
|
return products.filter((product) => !isBundleProduct(product));
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const GET_PRODUCTS = gql`
|
|||||||
products(channel: $channel, first: $first) {
|
products(channel: $channel, first: $first) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
...ProductListItemFragment
|
...ProductFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pageInfo {
|
pageInfo {
|
||||||
@@ -15,7 +15,7 @@ export const GET_PRODUCTS = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
${PRODUCT_LIST_ITEM_FRAGMENT}
|
${PRODUCT_FRAGMENT}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const GET_PRODUCT_BY_SLUG = gql`
|
export const GET_PRODUCT_BY_SLUG = gql`
|
||||||
@@ -49,3 +49,16 @@ export const GET_PRODUCTS_BY_CATEGORY = gql`
|
|||||||
}
|
}
|
||||||
${PRODUCT_LIST_ITEM_FRAGMENT}
|
${PRODUCT_LIST_ITEM_FRAGMENT}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const GET_BUNDLE_PRODUCTS = gql`
|
||||||
|
query GetBundleProducts($channel: String!, $locale: LanguageCodeEnum!, $first: Int!) {
|
||||||
|
products(channel: $channel, first: $first) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
...ProductFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${PRODUCT_FRAGMENT}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ export interface ProductMedia {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductAttributeValue {
|
export interface ProductAttributeValue {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductAttribute {
|
export interface ProductAttribute {
|
||||||
attribute: {
|
attribute: {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
};
|
||||||
@@ -82,6 +84,7 @@ export interface Product {
|
|||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
}[];
|
}[];
|
||||||
|
attributes?: ProductAttribute[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductEdge {
|
export interface ProductEdge {
|
||||||
|
|||||||
Reference in New Issue
Block a user