10 Commits

Author SHA1 Message Date
Unchained
bf628f873f fix(webhook): prevent duplicate customer emails by only sending on ORDER_CONFIRMED
Some checks are pending
Build and Deploy / build (push) Waiting to run
Both ORDER_CREATED and ORDER_CONFIRMED were sending customer emails,
causing duplicates. Now only ORDER_CONFIRMED sends customer emails,
while both events still notify admins.
2026-03-25 16:17:35 +02:00
Unchained
eb311568db fix(webhook): get currency from channel.currency_code instead of top-level
Some checks failed
Build and Deploy / build (push) Has been cancelled
Saleor webhook payload stores currency in channel.currency_code,
not as a top-level currency field. Updated interfaces and conversion
function to use the correct location.
2026-03-25 15:32:49 +02:00
Unchained
c9aaacc452 fix(webhook): handle Saleor legacy webhook payload format with snake_case fields
Some checks failed
Build and Deploy / build (push) Has been cancelled
Saleor sends webhook payloads as arrays with snake_case fields:
- user_email instead of userEmail
- billing_address instead of billingAddress
- total_gross_amount instead of total.gross.amount
- etc.

Added convertPayloadToOrder() function to transform snake_case payload
to camelCase format expected by our email templates.
2026-03-25 15:22:40 +02:00
Unchained
e08e919e83 fix(webhook): handle Saleor subscription payload format (array)
Some checks failed
Build and Deploy / build (push) Has been cancelled
Saleor sends webhook payloads as arrays in subscription format.
This fix extracts the order from the array or object format.
2026-03-25 15:10:45 +02:00
Unchained
923f805d47 fix(webhook): normalize event names to uppercase for case-insensitive matching
Some checks failed
Build and Deploy / build (push) Has been cancelled
Saleor sends event names in lowercase (order_created) but our code
expects uppercase (ORDER_CREATED). This fix normalizes the event
name before checking supported events.
2026-03-25 14:59:32 +02:00
Unchained
6e0a05c314 fix(k8s): add RESEND_API_KEY and DASHBOARD_URL env vars to deployment
Some checks failed
Build and Deploy / build (push) Has been cancelled
2026-03-25 14:26:56 +02:00
Unchained
5576946829 feat(emails): implement transactional email system with Resend
Some checks failed
Build and Deploy / build (push) Has been cancelled
- Add Resend email integration with @react-email/render
- Create email templates: OrderConfirmation, OrderShipped, OrderCancelled, OrderPaid
- Implement webhook handler for ORDER_CREATED and other events
- Add multi-language support for customer emails
- Admin emails in English with order details
- Update checkout page with auto-scroll on order completion
- Configure DASHBOARD_URL environment variable
2026-03-25 14:13:34 +02:00
Unchained
ef83538d0b fix: add required email and country fields to checkout
- Add email field (required) for order confirmation
- Add phone field in contact info section
- Add country dropdown with regional options
- Add validation for email format and required fields
- Add checkoutEmailUpdate mutation call before completing
- Use selected country instead of hardcoded RS
- Add translations for new fields (EN, SR, DE, FR)
2026-03-25 10:50:42 +02:00
Unchained
4fcd4b3ba8 refactor: abstract site URL across email templates
- Add NEXT_PUBLIC_SITE_URL to .env.local
- Update email templates to accept siteUrl prop
- Update webhook handler to pass siteUrl from env var
- Update create-webhooks.graphql with placeholder URL
2026-03-25 10:33:03 +02:00
Unchained
b8b3a57e6f 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
2026-03-25 10:10:57 +02:00
18 changed files with 2932 additions and 53 deletions

View File

@@ -76,6 +76,8 @@ spec:
value: "https://api.manoonoils.com/graphql/"
- name: NEXT_PUBLIC_SITE_URL
value: "https://dev.manoonoils.com"
- name: DASHBOARD_URL
value: "https://dashboard.manoonoils.com"
volumeMounts:
- name: workspace
mountPath: /workspace
@@ -108,6 +110,10 @@ spec:
value: "https://api.manoonoils.com/graphql/"
- name: NEXT_PUBLIC_SITE_URL
value: "https://dev.manoonoils.com"
- name: DASHBOARD_URL
value: "https://dashboard.manoonoils.com"
- name: RESEND_API_KEY
value: "re_bewcjHuy_DHtksWVUxguj8vFzKiJZNkFi"
resources:
limits:
cpu: 500m

615
package-lock.json generated
View File

@@ -9,6 +9,8 @@
"version": "0.1.0",
"dependencies": {
"@apollo/client": "^4.1.6",
"@react-email/components": "^1.0.10",
"@react-email/render": "^2.0.4",
"clsx": "^2.1.1",
"framer-motion": "^12.34.4",
"graphql": "^16.13.1",
@@ -17,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"
},
@@ -1644,6 +1647,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/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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1657,6 +1997,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 +3768,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 +3835,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 +3933,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 +4626,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 +5078,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 +5760,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 +6109,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 +6655,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 +6695,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 +6739,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 +6784,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 +6923,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 +7091,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 +7335,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 +7544,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 +7943,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",

View File

@@ -10,6 +10,8 @@
},
"dependencies": {
"@apollo/client": "^4.1.6",
"@react-email/components": "^1.0.10",
"@react-email/render": "^2.0.4",
"clsx": "^2.1.1",
"framer-motion": "^12.34.4",
"graphql": "^16.13.1",
@@ -18,6 +20,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"
},

View File

@@ -14,7 +14,11 @@ import {
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
CHECKOUT_BILLING_ADDRESS_UPDATE,
CHECKOUT_COMPLETE,
CHECKOUT_EMAIL_UPDATE,
CHECKOUT_METADATA_UPDATE,
CHECKOUT_SHIPPING_METHOD_UPDATE,
} from "@/lib/saleor/mutations/Checkout";
import { GET_CHECKOUT_BY_ID } from "@/lib/saleor/queries/Checkout";
import type { Checkout } from "@/types/saleor";
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 {
firstName: string;
lastName: string;
@@ -45,7 +86,9 @@ interface AddressForm {
streetAddress2: string;
city: string;
postalCode: string;
country: string;
phone: string;
email: string;
}
export default function CheckoutPage() {
@@ -66,7 +109,9 @@ export default function CheckoutPage() {
streetAddress2: "",
city: "",
postalCode: "",
country: "RS",
phone: "",
email: "",
});
const [billingAddress, setBillingAddress] = useState<AddressForm>({
firstName: "",
@@ -75,9 +120,15 @@ export default function CheckoutPage() {
streetAddress2: "",
city: "",
postalCode: "",
country: "RS",
phone: "",
email: "",
});
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
const [showShippingMethods, setShowShippingMethods] = useState(false);
const lines = getLines();
const total = getTotal();
@@ -87,9 +138,16 @@ export default function CheckoutPage() {
}
}, [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) => {
setShippingAddress((prev) => ({ ...prev, [field]: value }));
if (sameAsShipping) {
if (sameAsShipping && field !== "email") {
setBillingAddress((prev) => ({ ...prev, [field]: value }));
}
};
@@ -98,6 +156,10 @@ export default function CheckoutPage() {
setBillingAddress((prev) => ({ ...prev, [field]: value }));
};
const handleEmailChange = (value: string) => {
setShippingAddress((prev) => ({ ...prev, email: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -106,40 +168,80 @@ export default function CheckoutPage() {
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);
setError(null);
try {
const shippingResult = await saleorClient.mutate<ShippingAddressUpdateResponse>({
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
variables: {
checkoutId: checkout.id,
shippingAddress: {
...shippingAddress,
country: "RS",
},
},
});
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
throw new Error(shippingResult.data.checkoutShippingAddressUpdate.errors[0].message);
}
// If we're showing shipping methods and one is selected, complete the order
if (showShippingMethods && selectedShippingMethod) {
console.log("Phase 2: Completing order with shipping method...");
console.log("Step 1: Updating billing address...");
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
variables: {
checkoutId: checkout.id,
billingAddress: {
...billingAddress,
country: "RS",
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 (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
throw new Error(billingResult.data.checkoutBillingAddressUpdate.errors[0].message);
throw new Error(`Billing address update failed: ${billingResult.data.checkoutBillingAddressUpdate.errors[0].message}`);
}
console.log("Step 1: Billing address updated successfully");
console.log("Step 2: Setting shipping method...");
const shippingMethodResult = await saleorClient.mutate<ShippingMethodUpdateResponse>({
mutation: CHECKOUT_SHIPPING_METHOD_UPDATE,
variables: {
checkoutId: checkout.id,
shippingMethodId: selectedShippingMethod,
},
});
if (shippingMethodResult.data?.checkoutShippingMethodUpdate?.errors && shippingMethodResult.data.checkoutShippingMethodUpdate.errors.length > 0) {
throw new Error(`Shipping method update failed: ${shippingMethodResult.data.checkoutShippingMethodUpdate.errors[0].message}`);
}
console.log("Step 2: Shipping method set successfully");
console.log("Step 3: Saving phone number...");
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
mutation: CHECKOUT_METADATA_UPDATE,
variables: {
checkoutId: checkout.id,
metadata: [
{ key: "phone", value: shippingAddress.phone },
{ key: "shippingPhone", value: shippingAddress.phone },
],
},
});
if (metadataResult.data?.updateMetadata?.errors && metadataResult.data.updateMetadata.errors.length > 0) {
console.warn("Failed to save phone metadata:", metadataResult.data.updateMetadata.errors);
} else {
console.log("Step 3: Phone number saved successfully");
}
console.log("Step 4: Completing checkout...");
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
mutation: CHECKOUT_COMPLETE,
variables: {
@@ -158,9 +260,91 @@ export default function CheckoutPage() {
} else {
throw new Error(t("errorCreatingOrder"));
}
} else {
// Phase 1: Update email and address, then fetch shipping methods
console.log("Phase 1: Updating email and address...");
console.log("Step 1: Updating email...");
const emailResult = await saleorClient.mutate<EmailUpdateResponse>({
mutation: CHECKOUT_EMAIL_UPDATE,
variables: {
checkoutId: checkout.id,
email: shippingAddress.email,
},
});
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
throw new Error(`Email update failed: ${emailResult.data.checkoutEmailUpdate.errors[0].message}`);
}
console.log("Step 1: Email updated successfully");
console.log("Step 2: Updating shipping address...");
console.log("Shipping address data:", {
firstName: shippingAddress.firstName,
lastName: shippingAddress.lastName,
streetAddress1: shippingAddress.streetAddress1,
city: shippingAddress.city,
postalCode: shippingAddress.postalCode,
country: shippingAddress.country,
phone: shippingAddress.phone,
});
const shippingResult = await saleorClient.mutate<ShippingAddressUpdateResponse>({
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
variables: {
checkoutId: checkout.id,
shippingAddress: {
firstName: shippingAddress.firstName,
lastName: shippingAddress.lastName,
streetAddress1: shippingAddress.streetAddress1,
streetAddress2: shippingAddress.streetAddress2,
city: shippingAddress.city,
postalCode: shippingAddress.postalCode,
country: shippingAddress.country,
phone: shippingAddress.phone,
},
},
});
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
throw new Error(`Shipping address update failed: ${shippingResult.data.checkoutShippingAddressUpdate.errors[0].message}`);
}
console.log("Step 2: Shipping address updated successfully");
// Query for checkout to get available shipping methods
console.log("Step 3: Fetching shipping methods...");
const checkoutQueryResult = await saleorClient.query<CheckoutQueryResponse>({
query: GET_CHECKOUT_BY_ID,
variables: {
id: checkout.id,
},
fetchPolicy: "network-only",
});
const availableMethods = checkoutQueryResult.data?.checkout?.shippingMethods || [];
console.log("Available shipping methods:", availableMethods);
if (availableMethods.length === 0) {
throw new Error(t("errorNoShippingMethods"));
}
setShippingMethods(availableMethods);
setShowShippingMethods(true);
// Don't complete yet - show shipping method selection
console.log("Phase 1 complete. Waiting for shipping method selection...");
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : null;
setError(errorMessage || t("errorOccurred"));
console.error("Checkout error:", err);
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 {
setIsLoading(false);
}
@@ -227,6 +411,36 @@ export default function CheckoutPage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
<div>
<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">
<h2 className="text-xl font-serif mb-4">{t("shippingAddress")}</h2>
<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"
/>
</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">
<label className="block text-sm font-medium mb-1">{t("streetAddress")}</label>
<input
@@ -289,16 +532,6 @@ export default function CheckoutPage() {
className="w-full border border-border px-4 py-2 rounded"
/>
</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>
@@ -314,12 +547,49 @@ export default function CheckoutPage() {
</label>
</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
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"
>
{isLoading ? t("processing") : t("completeOrder", { total: formatPrice(total) })}
{isLoading ? t("processing") : showShippingMethods ? t("completeOrder", { total: formatPrice(total) }) : t("continueToShipping")}
</button>
</form>
</div>

View 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,
});
}

98
src/emails/BaseLayout.tsx Normal file
View 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,
},
};

View 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",
},
};

View 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
View 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
View 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
View 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";

View File

@@ -340,7 +340,13 @@
},
"Checkout": {
"checkout": "Kasse",
"contactInfo": "Kontaktinformationen",
"email": "E-Mail",
"emailRequired": "Erforderlich für Bestellbestätigung",
"phoneRequired": "Erforderlich für Lieferkoordination",
"shippingAddress": "Lieferadresse",
"shippingMethod": "Versandart",
"country": "Land",
"firstName": "Vorname",
"lastName": "Nachname",
"streetAddress": "Straße und Nummer",
@@ -364,6 +370,8 @@
"yourCartEmpty": "Ihr Warenkorb ist leer",
"continueShopping": "Weiter einkaufen",
"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.",
"errorCreatingOrder": "Bestellung konnte nicht erstellt werden.",
"orderConfirmed": "Bestellung bestätigt!",

View File

@@ -386,7 +386,13 @@
},
"Checkout": {
"checkout": "Checkout",
"contactInfo": "Contact Information",
"email": "Email",
"emailRequired": "Required for order confirmation",
"phoneRequired": "Required for delivery coordination",
"shippingAddress": "Shipping Address",
"shippingMethod": "Shipping Method",
"country": "Country",
"firstName": "First Name",
"lastName": "Last Name",
"streetAddress": "Street Address",
@@ -410,8 +416,13 @@
"yourCartEmpty": "Your cart is empty",
"continueShopping": "Continue Shopping",
"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.",
"errorCreatingOrder": "Failed to create order.",
"continueToShipping": "Continue to Shipping",
"orderConfirmed": "Order Confirmed!",
"thankYou": "Thank you for your purchase.",
"orderNumber": "Order Number",

View File

@@ -340,7 +340,13 @@
},
"Checkout": {
"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",
"shippingMethod": "Méthode de livraison",
"country": "Pays",
"firstName": "Prénom",
"lastName": "Nom",
"streetAddress": "Rue et Numéro",
@@ -364,6 +370,8 @@
"yourCartEmpty": "Votre panier est vide",
"continueShopping": "Continuer les Achats",
"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.",
"errorCreatingOrder": "Échec de la création de la commande.",
"orderConfirmed": "Commande Confirmée!",

View File

@@ -386,7 +386,13 @@
},
"Checkout": {
"checkout": "Kupovina",
"contactInfo": "Kontakt informacije",
"email": "Email",
"emailRequired": "Potrebno za potvrdu narudžbine",
"phoneRequired": "Potrebno za koordinaciju dostave",
"shippingAddress": "Adresa za dostavu",
"shippingMethod": "Način dostave",
"country": "Država",
"firstName": "Ime",
"lastName": "Prezime",
"streetAddress": "Ulica i broj",
@@ -410,6 +416,8 @@
"yourCartEmpty": "Vaša korpa je prazna",
"continueShopping": "Nastavi kupovinu",
"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.",
"errorCreatingOrder": "Neuspešno kreiranje narudžbine.",
"orderConfirmed": "Narudžbina potvrđena!",

106
src/lib/resend.ts Normal file
View 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}`,
});
}

View 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
}
}
}

View File

@@ -152,3 +152,24 @@ export const CHECKOUT_EMAIL_UPDATE = gql`
}
${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
}
}
}
`;