From b8b3a57e6f36a0dc44e3820d098bccdd3ba34383 Mon Sep 17 00:00:00 2001 From: Unchained Date: Wed, 25 Mar 2026 10:10:57 +0200 Subject: [PATCH] feat: Add Saleor webhook handler with Resend email integration - Add Resend SDK for transactional emails - Create React Email templates for order events: - OrderConfirmation - OrderShipped - OrderCancelled - OrderPaid - Multi-language support (SR, EN, DE, FR) - Customer emails in their language - Admin emails in English to me@hytham.me and tamara@hytham.me - Webhook handler at /api/webhooks/saleor - Supports: ORDER_CONFIRMED, ORDER_FULLY_PAID, ORDER_CANCELLED, ORDER_FULFILLED - Add GraphQL mutation to create webhooks in Saleor - Add Resend API key to .env.local --- package-lock.json | 614 +++++++++++++++++++++++++ package.json | 2 + src/app/api/webhooks/saleor/route.ts | 411 +++++++++++++++++ src/emails/BaseLayout.tsx | 97 ++++ src/emails/OrderCancelled.tsx | 235 ++++++++++ src/emails/OrderConfirmation.tsx | 266 +++++++++++ src/emails/OrderPaid.tsx | 251 ++++++++++ src/emails/OrderShipped.tsx | 191 ++++++++ src/emails/index.ts | 5 + src/lib/resend.ts | 100 ++++ src/lib/saleor/create-webhooks.graphql | 77 ++++ 11 files changed, 2249 insertions(+) create mode 100644 src/app/api/webhooks/saleor/route.ts create mode 100644 src/emails/BaseLayout.tsx create mode 100644 src/emails/OrderCancelled.tsx create mode 100644 src/emails/OrderConfirmation.tsx create mode 100644 src/emails/OrderPaid.tsx create mode 100644 src/emails/OrderShipped.tsx create mode 100644 src/emails/index.ts create mode 100644 src/lib/resend.ts create mode 100644 src/lib/saleor/create-webhooks.graphql diff --git a/package-lock.json b/package-lock.json index 9d04153..b959dc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@apollo/client": "^4.1.6", + "@react-email/components": "^1.0.10", "clsx": "^2.1.1", "framer-motion": "^12.34.4", "graphql": "^16.13.1", @@ -17,6 +18,7 @@ "next-intl": "^4.8.3", "react": "19.2.3", "react-dom": "19.2.3", + "resend": "^6.9.4", "tailwind-merge": "^3.5.0", "zustand": "^5.0.11" }, @@ -1644,6 +1646,343 @@ "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/components/node_modules/@react-email/render": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.4.tgz", + "integrity": "sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g==", + "license": "MIT", + "dependencies": { + "html-to-text": "^9.0.5", + "prettier": "^3.5.3" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": "^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" + } + }, + "node_modules/@react-email/container": { + "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/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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1657,6 +1996,25 @@ "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", "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": { "version": "1.15.18", "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.18.tgz", @@ -3409,6 +3767,15 @@ "dev": true, "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": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -3467,6 +3834,61 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3510,6 +3932,18 @@ "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": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -4191,6 +4625,12 @@ "dev": true, "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": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -4637,6 +5077,41 @@ "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": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.3.tgz", @@ -5284,6 +5759,15 @@ "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": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5624,6 +6108,18 @@ "@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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6158,6 +6654,19 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -6185,6 +6694,15 @@ "dev": true, "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6220,6 +6738,12 @@ "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": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -6259,6 +6783,30 @@ "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": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6374,6 +6922,27 @@ "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": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6521,6 +7090,18 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "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": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6753,6 +7334,16 @@ "dev": true, "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -6952,6 +7543,16 @@ "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": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -7341,6 +7942,19 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 0e99b00..2bb0bc8 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@apollo/client": "^4.1.6", + "@react-email/components": "^1.0.10", "clsx": "^2.1.1", "framer-motion": "^12.34.4", "graphql": "^16.13.1", @@ -18,6 +19,7 @@ "next-intl": "^4.8.3", "react": "19.2.3", "react-dom": "19.2.3", + "resend": "^6.9.4", "tailwind-merge": "^3.5.0", "zustand": "^5.0.11" }, diff --git a/src/app/api/webhooks/saleor/route.ts b/src/app/api/webhooks/saleor/route.ts new file mode 100644 index 0000000..e509911 --- /dev/null +++ b/src/app/api/webhooks/saleor/route.ts @@ -0,0 +1,411 @@ +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"; + +interface SaleorWebhookHeaders { + "saleor-event": string; + "saleor-domain": string; + "saleor-signature"?: string; + "saleor-api-url": string; +} + +interface SaleorLineItem { + id: string; + productName: string; + variantName?: string; + quantity: number; + quantityUnit?: string; + 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_CONFIRMED", + "ORDER_FULLY_PAID", + "ORDER_CANCELLED", + "ORDER_FULFILLED", +]; + +const LANGUAGE_CODE_MAP: Record = { + SR: "sr", + EN: "en", + DE: "de", + FR: "fr", +}; + +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) { + 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" + ? `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), + }), + language, + idempotencyKey: `order-confirmed/${order.id}`, + }); + + await sendEmailToAdmin({ + subject: `New Order #${order.number} - ${customerName}`, + react: OrderConfirmation({ + language: "en", + orderId: order.id, + orderNumber: order.number, + customerEmail, + customerName, + items: parseOrderItems(order.lines, currency), + total: formatPrice(order.total.gross.amount, currency), + shippingAddress: formatAddress(order.shippingAddress), + }), + 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, + }), + 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, + }), + 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, + }), + 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, + }), + 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), + }), + 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), + }), + 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_CONFIRMED": + await handleOrderConfirmed(order); + 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 { + 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}`); + + if (!event) { + return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 }); + } + + if (!SUPPORTED_EVENTS.includes(event)) { + console.log(`Event ${event} not supported, skipping`); + return NextResponse.json({ success: true, message: "Event not supported" }); + } + + const payload = body; + + await handleSaleorWebhook(event, payload); + + 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, + }); +} diff --git a/src/emails/BaseLayout.tsx b/src/emails/BaseLayout.tsx new file mode 100644 index 0000000..6e65f6a --- /dev/null +++ b/src/emails/BaseLayout.tsx @@ -0,0 +1,97 @@ +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; +} + +const translations: Record = { + 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 }: BaseLayoutProps) { + const t = translations[language] || translations.en; + + return ( + + + {previewText} + + +
+ ManoonOils +
+ {children} +
+ {t.footer} +
+
+ + + ); +} + +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, + }, +}; diff --git a/src/emails/OrderCancelled.tsx b/src/emails/OrderCancelled.tsx new file mode 100644 index 0000000..4f45740 --- /dev/null +++ b/src/emails/OrderCancelled.tsx @@ -0,0 +1,235 @@ +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; +} + +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, +}: OrderCancelledProps) { + const t = translations[language] || translations.en; + + return ( + + {t.title} + {t.greeting.replace("{name}", customerName)} + {t.orderCancelled} + +
+ + Order Number: {orderNumber} + + {reason && ( + + {t.reason}: {reason} + + )} +
+ +
+ {t.items} +
+ {items.map((item) => ( +
+ + {item.quantity}x {item.name} + + {item.price} +
+ ))} +
+
+ {t.total}: + {total} +
+
+ +
+ +
+ + {t.questions} +
+ ); +} + +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", + }, +}; diff --git a/src/emails/OrderConfirmation.tsx b/src/emails/OrderConfirmation.tsx new file mode 100644 index 0000000..d0fe0cd --- /dev/null +++ b/src/emails/OrderConfirmation.tsx @@ -0,0 +1,266 @@ +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; +} + +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; + } +> = { + 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!", + }, + 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!", + }, + 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!", + }, + 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!", + }, +}; + +export function OrderConfirmation({ + language = "en", + orderId, + orderNumber, + customerEmail, + customerName, + items, + total, + shippingAddress, +}: OrderConfirmationProps) { + const t = translations[language] || translations.en; + + return ( + + {t.title} + {t.greeting.replace("{name}", customerName)} + {t.orderReceived} + +
+ + {t.orderNumber}: {orderNumber} + +
+ +
+ {t.items} +
+ {items.map((item) => ( +
+ + {item.quantity}x {item.name} + + {item.price} +
+ ))} +
+
+ {t.total}: + {total} +
+
+ + {shippingAddress && ( +
+ {t.shippingTo} + {shippingAddress} +
+ )} + +
+ +
+ + {t.questions} + {t.thankYou} +
+ ); +} + +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", + }, + 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", + }, +}; diff --git a/src/emails/OrderPaid.tsx b/src/emails/OrderPaid.tsx new file mode 100644 index 0000000..26d97a9 --- /dev/null +++ b/src/emails/OrderPaid.tsx @@ -0,0 +1,251 @@ +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; +} + +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, +}: OrderPaidProps) { + const t = translations[language] || translations.en; + + return ( + + {t.title} + {t.greeting.replace("{name}", customerName)} + {t.orderPaid} + +
+ + Order Number: {orderNumber} + +
+ +
+ {t.items} +
+ {items.map((item) => ( +
+ + {item.quantity}x {item.name} + + {item.price} +
+ ))} +
+
+ {t.total}: + {total} +
+
+ +
+ {t.nextSteps} + {t.nextStepsText} +
+ +
+ +
+ + {t.questions} +
+ ); +} + +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", + }, +}; diff --git a/src/emails/OrderShipped.tsx b/src/emails/OrderShipped.tsx new file mode 100644 index 0000000..473cd75 --- /dev/null +++ b/src/emails/OrderShipped.tsx @@ -0,0 +1,191 @@ +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; +} + +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, +}: OrderShippedProps) { + const t = translations[language] || translations.en; + + return ( + + {t.title} + {t.greeting.replace("{name}", customerName)} + {t.orderShipped} + + {trackingNumber && ( +
+ {t.tracking} + {trackingUrl ? ( + + ) : ( + {trackingNumber} + )} +
+ )} + +
+ {t.items} +
+ {items.map((item) => ( +
+ + {item.quantity}x {item.name} + + {item.price} +
+ ))} +
+ + {t.questions} +
+ ); +} + +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", + }, +}; diff --git a/src/emails/index.ts b/src/emails/index.ts new file mode 100644 index 0000000..ea354ff --- /dev/null +++ b/src/emails/index.ts @@ -0,0 +1,5 @@ +export { BaseLayout } from "./BaseLayout"; +export { OrderConfirmation } from "./OrderConfirmation"; +export { OrderShipped } from "./OrderShipped"; +export { OrderCancelled } from "./OrderCancelled"; +export { OrderPaid } from "./OrderPaid"; diff --git a/src/lib/resend.ts b/src/lib/resend.ts new file mode 100644 index 0000000..a61c130 --- /dev/null +++ b/src/lib/resend.ts @@ -0,0 +1,100 @@ +import { Resend } from "resend"; + +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(); + const { data, error } = await resend.emails.send( + { + from: "ManoonOils ", + to: Array.isArray(to) ? to : [to], + subject, + react, + 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}`, + }); +} diff --git a/src/lib/saleor/create-webhooks.graphql b/src/lib/saleor/create-webhooks.graphql new file mode 100644 index 0000000..1994060 --- /dev/null +++ b/src/lib/saleor/create-webhooks.graphql @@ -0,0 +1,77 @@ +mutation CreateSaleorWebhooks { + orderConfirmedWebhook: webhookCreate(input: { + name: "Resend - Order Confirmed" + targetUrl: "https://manoonoils.com/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: "https://manoonoils.com/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: "https://manoonoils.com/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: "https://manoonoils.com/api/webhooks/saleor" + events: [ORDER_FULFILLED] + isActive: true + }) { + webhook { + id + name + targetUrl + isActive + } + errors { + field + message + code + } + } +}