From 28b864ecdb288d6f1ac12a2f17e90779a0835d25 Mon Sep 17 00:00:00 2001 From: Unchained Date: Sat, 28 Mar 2026 17:06:36 +0200 Subject: [PATCH] docs: add comprehensive roadmap for email service improvements --- ROADMAP.md | 312 +++++++++++++++++++++ package.json | 1 + pnpm-lock.yaml | 38 ++- src/lib/email-templates/index.ts | 3 + src/lib/email-templates/order-cancelled.ts | 207 ++++++++++++++ src/lib/email-templates/order-created.ts | 287 +++++++++++++++++++ src/lib/email-templates/order-shipped.ts | 214 ++++++++++++++ src/pages/api/webhooks/order-cancelled.ts | 55 ++-- src/pages/api/webhooks/order-created.ts | 55 ++-- src/pages/api/webhooks/order-fulfilled.ts | 59 ++-- 10 files changed, 1172 insertions(+), 59 deletions(-) create mode 100644 ROADMAP.md create mode 100644 src/lib/email-templates/index.ts create mode 100644 src/lib/email-templates/order-cancelled.ts create mode 100644 src/lib/email-templates/order-created.ts create mode 100644 src/lib/email-templates/order-shipped.ts diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..0917a4d --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,312 @@ +# Core-Extensions Email Service - Roadmap + +## Current Status ✅ + +- **Order Created**: Working perfectly (customer name, phone, all languages) +- **Order Fulfilled**: Working but shows email instead of customer name +- **Order Cancelled**: Working but shows email instead of customer name +- **All webhooks firing**: ✅ Emails sent via Resend +- **Multi-language support**: ✅ Working (tested all 4 languages - SR, EN, DE, FR) +- **Admin notifications**: ✅ Both admins receiving emails + +--- + +## PHASE 1: Fix Broken Things (Priority) + +### 1.1 Fix Customer Name in Fulfilled/Cancelled Emails + +**Problem**: Fulfilled and Cancelled webhooks receive partial data from Saleor (no `shippingAddress.firstName`, no `languageCode`) + +**Solution**: Query Saleor API when these webhooks fire to get full order details + +**Files to modify**: +- `src/pages/api/webhooks/order-fulfilled.ts` +- `src/pages/api/webhooks/order-cancelled.ts` + +**Tasks**: +- [ ] Add GraphQL query to fetch full order data including `shippingAddress` and `languageCode` +- [ ] Update email template functions to use complete order data +- [ ] Test with real fulfillments and cancellations + +**Effort**: Medium +**Impact**: High (fixes broken UX) + +--- + +### 1.2 Fix Environment Variables Usage + +**Problem**: Hardcoded values in webhook files override env vars + +**Current**: +```typescript +const ADMIN_EMAILS = ["me@hytham.me", "tamara@hytham.me"]; +from: "Manoon Oils " +``` + +**Should be**: +```typescript +const ADMIN_EMAILS = process.env.ADMIN_EMAILS?.split(",") || []; +from: `${process.env.FROM_NAME} <${process.env.FROM_EMAIL}>` +``` + +**Files to modify**: +- `src/pages/api/webhooks/order-created.ts` +- `src/pages/api/webhooks/order-fulfilled.ts` +- `src/pages/api/webhooks/order-cancelled.ts` + +**Tasks**: +- [ ] Replace hardcoded `ADMIN_EMAILS` with env var +- [ ] Replace hardcoded `from` addresses with env vars +- [ ] Update `.env.example` with all required variables +- [ ] Document env vars in README + +**Effort**: Low +**Impact**: Medium (first step toward reusability) + +--- + +### 1.3 Fix Resend Daily Quota Issue + +**Problem**: Multiple emails per order (3 customer + 6 admin = 9 emails per order) + +**Solutions to consider**: +1. Batch admin emails (send one email with multiple recipients) +2. Add option to disable admin emails for testing +3. Use BCC for admin emails + +**Tasks**: +- [ ] Change admin email sending to use single email with multiple recipients or BCC +- [ ] Add `DISABLE_ADMIN_EMAILS` env var for testing +- [ ] Consider batching order confirmations + +**Effort**: Low +**Impact**: High (saves quota) + +--- + +## PHASE 2: Make It Reusable (Foundation) + +### 2.1 Extract Brand Configuration to Env Vars + +**Problem**: 20+ hardcoded Manoon Oils references + +**Files to modify**: +- `src/lib/email-templates/order-created.ts` +- `src/lib/email-templates/order-shipped.ts` +- `src/lib/email-templates/order-cancelled.ts` + +**New env vars needed**: +``` +COMPANY_NAME=Manoon Oils +LOGO_URL=https://minio-api.nodecrew.me/... +SITE_URL=https://manoonoils.com +DASHBOARD_URL=https://dashboard.manoonoils.com +TRACKING_URL_TEMPLATE=https://track.manoonoils.com/{trackingNumber} +SUPPORT_EMAIL=support@manoonoils.com +``` + +**Tasks**: +- [ ] Replace all hardcoded company names +- [ ] Replace all hardcoded URLs +- [ ] Replace logo URL +- [ ] Update all email templates + +**Effort**: Medium +**Impact**: High (enables reusability) + +--- + +### 2.2 Unify Email Template System + +**Problem**: Two competing systems (React Email vs Handlebars) + +**Decision needed**: +- **Option A**: Stick with Handlebars (simpler, already working) +- **Option B**: Migrate to React Email (type-safe, better for complex layouts) +- **Option C**: Support both (more complex) + +**Recommendation**: Stick with Handlebars for now (it's working), document the React Email files as "future improvement" + +**Tasks**: +- [ ] Document the two systems in README +- [ ] Remove unused React Email code OR move to separate folder +- [ ] Keep Handlebars as primary system + +**Effort**: Low +**Impact**: Low (cleanup) + +--- + +## PHASE 3: Advanced Features + +### 3.1 Configuration via Saleor Metadata + +**Problem**: Currently requires code changes to customize + +**Solution**: Store configuration in Saleor's app metadata + +**Benefits**: +- No code changes needed for new stores +- Configurable via Saleor dashboard +- Single deployment serves multiple stores + +**Files to modify**: +- Add configuration service +- Modify all webhook handlers to fetch config +- Add settings UI in app + +**Tasks**: +- [ ] Create configuration interface +- [ ] Add GraphQL queries to fetch config from Saleor metadata +- [ ] Update webhook handlers to use dynamic config +- [ ] Create settings page in app +- [ ] Cache configuration to avoid API calls on every webhook + +**Effort**: High +**Impact**: High (true multi-tenancy) + +--- + +### 3.2 Add Email Preview/Test Feature + +**Problem**: Hard to test email templates without creating real orders + +**Solution**: Add API endpoint to preview/test emails + +**Tasks**: +- [ ] Create `/api/test-email` endpoint +- [ ] Allow sending test emails to any address +- [ ] Include sample order data for preview +- [ ] Add to app UI for easy testing + +**Effort**: Medium +**Impact**: Medium (developer experience) + +--- + +### 3.3 Phone Number Validation + +**Problem**: Phone validation failing for some formats (GB numbers rejected) + +**Solution**: Make phone optional OR improve validation + +**Tasks**: +- [ ] Investigate Saleor's phone validation +- [ ] Either fix validation or make phone optional in templates +- [ ] Update GraphQL fragments if needed + +**Effort**: Low +**Impact**: Low (edge case) + +--- + +## PHASE 4: Documentation & Deployment + +### 4.1 Comprehensive README + +**Sections needed**: +- Installation instructions +- Environment variables reference +- Webhook configuration guide +- Testing instructions +- Troubleshooting + +### 4.2 Docker Compose for Development + +- Local Saleor instance for testing +- Easy development setup + +### 4.3 CI/CD Pipeline + +- Automated testing +- Automated deployment +- Version tagging + +--- + +## 🎯 Recommended Order of Work + +### Next (Do First): +1. **Fix Customer Name in Fulfilled/Cancelled** - Broken UX, high impact +2. **Fix Env Var Usage** - Quick win, enables reusability +3. **Fix Quota Issue** - Save money immediately + +### Then: +4. **Extract Brand Config** - Makes it reusable +5. **Documentation** - Helps with future work + +### Later: +6. **Saleor Metadata Config** - True multi-tenancy +7. **Email Preview Feature** - Nice to have +8. **Advanced Features** - When needed + +--- + +## Architecture Notes + +### Current Hardcoded Values + +#### Brand/Company Identity: +- Company name: "ManoonOils" (12+ instances) +- Logo URL: MinIO URL to Manoon logo +- Support email: support@manoonoils.com + +#### URLs: +- Site: https://manoonoils.com +- Dashboard: https://dashboard.manoonoils.com/orders +- Tracking: https://track.manoonoils.com/{trackingNumber} + +#### Email Addresses: +- Admin recipients: me@hytham.me, tamara@hytham.me +- From address: Manoon Oils + +### Two Email Systems + +1. **React Email** (`/src/emails/*.tsx`) + - Type-safe React components + - Currently unused in webhooks + - Good for complex layouts + +2. **Handlebars** (`/src/lib/email-templates/*.ts`) + - Simple string templates + - Currently used by webhooks + - Easy to customize with translations + +**Recommendation**: Stick with Handlebars for production use + +### Webhook Data Inconsistency + +Saleor sends different data for different events: + +- **ORDER_CREATED**: Full order data ✅ +- **ORDER_FULFILLED**: Partial data (missing shippingAddress details, languageCode) ❌ +- **ORDER_CANCELLED**: Partial data (missing shippingAddress details, languageCode) ❌ + +**Fix**: Query Saleor API in fulfilled/cancelled webhooks to fetch complete order data + +--- + +## Environment Variables Reference + +### Current (Working): +```bash +RESEND_API_KEY= # Required - Resend API key +``` + +### Needed (Not implemented): +```bash +ADMIN_EMAILS= # Comma-separated admin emails +FROM_EMAIL= # Sender email address +FROM_NAME= # Sender display name +COMPANY_NAME= # Company/brand name +LOGO_URL= # URL to logo image +SITE_URL= # Customer-facing storefront URL +DASHBOARD_URL= # Admin dashboard URL +TRACKING_URL_TEMPLATE= # Tracking URL with {trackingNumber} placeholder +SUPPORT_EMAIL= # Support email shown in templates +DISABLE_ADMIN_EMAILS= # Set to "true" to disable admin notifications +``` + +--- + +*Last Updated: March 28, 2026* diff --git a/package.json b/package.json index f4db5be..5948048 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@saleor/app-sdk": "1.5.0", "@saleor/macaw-ui": "1.4.1", "@urql/exchange-auth": "^1.0.0", + "handlebars": "^4.7.9", "next": "15.5.9", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdb46bd..3265b17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@urql/exchange-auth': specifier: ^1.0.0 version: 1.0.0(graphql@16.8.1) + handlebars: + specifier: ^4.7.9 + version: 4.7.9 next: specifier: 15.5.9 version: 15.5.9(@babel/core@7.24.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3302,6 +3305,11 @@ packages: resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} @@ -3865,6 +3873,9 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + next@15.5.9: resolution: {integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -4735,6 +4746,11 @@ packages: ufo@1.5.3: resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==} + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} @@ -4951,6 +4967,9 @@ packages: wonka@6.3.4: resolution: {integrity: sha512-CjpbqNtBGNAeyNS/9W6q3kSkKE52+FjIj7AkFlLr11s/VWGUu6a2CdYSdGxocIhIVjaW/zchesBQUKPVU69Cqg==} + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -8769,6 +8788,15 @@ snapshots: graphql@16.8.1: {} + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + has-bigints@1.0.2: {} has-flag@3.0.0: {} @@ -9357,6 +9385,8 @@ snapshots: natural-compare@1.4.0: {} + neo-async@2.6.2: {} + next@15.5.9(@babel/core@7.24.0)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 15.5.9 @@ -9995,8 +10025,7 @@ snapshots: source-map-js@1.2.0: {} - source-map@0.6.1: - optional: true + source-map@0.6.1: {} spawndamnit@2.0.0: dependencies: @@ -10249,6 +10278,9 @@ snapshots: ufo@1.5.3: {} + uglify-js@3.19.3: + optional: true + unbox-primitive@1.0.2: dependencies: call-bind: 1.0.2 @@ -10492,6 +10524,8 @@ snapshots: wonka@6.3.4: {} + wordwrap@1.0.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 diff --git a/src/lib/email-templates/index.ts b/src/lib/email-templates/index.ts new file mode 100644 index 0000000..4032022 --- /dev/null +++ b/src/lib/email-templates/index.ts @@ -0,0 +1,3 @@ +export { getOrderCreatedEmails } from "./order-created"; +export { getOrderShippedEmails } from "./order-shipped"; +export { getOrderCancelledEmails } from "./order-cancelled"; diff --git a/src/lib/email-templates/order-cancelled.ts b/src/lib/email-templates/order-cancelled.ts new file mode 100644 index 0000000..c37b2c6 --- /dev/null +++ b/src/lib/email-templates/order-cancelled.ts @@ -0,0 +1,207 @@ +import Handlebars from "handlebars"; + +// Translations for Order Cancelled emails +const translations = { + en: { + subject: "Order {{orderNumber}} Cancelled", + title: "Order Cancelled", + greeting: "Dear {{customerName}},", + body: "Your order has been cancelled. If you have any questions, please contact us.", + orderLabel: "Order:", + dateLabel: "Date:", + reasonLabel: "Reason:", + noReason: "No reason provided", + itemsLabel: "Items", + contactButton: "Contact Us", + thankYou: "Thank you!", + companyName: "ManoonOils", + footerText: "support@manoonoils.com" + }, + sr: { + subject: "Porudžbina {{orderNumber}} Otkazana", + title: "Porudžbina Otkazana", + greeting: "Poštovani {{customerName}},", + body: "Vaša porudžbina je otkazana. Ako imate pitanja, molimo vas da nas kontaktirate.", + orderLabel: "Porudžbina:", + dateLabel: "Datum:", + reasonLabel: "Razlog:", + noReason: "Razlog nije naveden", + itemsLabel: "Artikli", + contactButton: "Kontaktirajte Nas", + thankYou: "Hvala vam!", + companyName: "ManoonOils", + footerText: "support@manoonoils.com" + }, + de: { + subject: "Bestellung {{orderNumber}} Storniert", + title: "Bestellung Storniert", + greeting: "Sehr geehrte/r {{customerName}},", + body: "Ihre Bestellung wurde storniert. Bei Fragen kontaktieren Sie uns bitte.", + orderLabel: "Bestellung:", + dateLabel: "Datum:", + reasonLabel: "Grund:", + noReason: "Kein Grund angegeben", + itemsLabel: "Artikel", + contactButton: "Kontaktieren Sie Uns", + thankYou: "Vielen Dank!", + companyName: "ManoonOils", + footerText: "support@manoonoils.com" + }, + fr: { + subject: "Commande {{orderNumber}} Annulée", + title: "Commande Annulée", + greeting: "Cher/Chère {{customerName}},", + body: "Votre commande a été annulée. Si vous avez des questions, veuillez nous contacter.", + orderLabel: "Commande:", + dateLabel: "Date:", + reasonLabel: "Raison:", + noReason: "Aucune raison fournie", + itemsLabel: "Articles", + contactButton: "Contactez-Nous", + thankYou: "Merci!", + companyName: "ManoonOils", + footerText: "support@manoonoils.com" + } +}; + +// Customer email template +const customerEmailTemplate = ` + + + + + + +
+
+ {{companyName}} +
+ +

{{title}}

+ +

{{greeting}}

+

{{body}}

+ +
+

{{orderLabel}} {{orderNumber}}

+

{{dateLabel}} {{orderDate}}

+

{{reasonLabel}} {{cancellationReason}}

+
+ +

{{itemsLabel}}

+
+ {{#each items}} +

{{quantity}}x {{name}} - {{price}}

+ {{/each}} +
+ +
+ {{contactButton}} +
+ +

{{footerText}}

+

{{thankYou}}

+ +
+

{{companyName}}

+
+
+ + +`; + +// Admin email template +const adminEmailTemplate = ` + + + + + + +
+

Order Cancelled

+ +
+

Order #{{orderNumber}}

+

Date: {{orderDate}}

+

Reason: {{cancellationReason}}

+
+ +
+

Customer

+

Name: {{customerName}}

+

Email: {{customerEmail}}

+

Phone: {{#if phone}}{{phone}}{{else}}Not provided{{/if}}

+
+ +

Items

+
+ {{#each items}} +

{{quantity}}x {{name}} - {{price}}

+ {{/each}} +
+ +
+ Dashboard +
+
+ + +`; + +// Compile templates +const compileCustomerEmail = Handlebars.compile(customerEmailTemplate); +const compileAdminEmail = Handlebars.compile(adminEmailTemplate); + +export function getOrderCancelledEmails(order: any) { + // Detect language + const lang = order.languageCode?.toLowerCase() || "en"; + const t = translations[lang as keyof typeof translations] || translations.en; + + // Format data + const customerName = order.shippingAddress?.firstName + ? `${order.shippingAddress.firstName} ${order.shippingAddress.lastName || ""}`.trim() + : order.userEmail?.split("@")[0] || "Customer"; + + const currency = order.total?.gross?.currency || "EUR"; + + const items = (order.lines || []).map((line: any) => ({ + name: line.variant?.product?.name || line.variant?.name || "Product", + quantity: line.quantity || 0, + price: `${line.totalPrice?.gross?.amount?.toFixed(2) || "0.00"} ${currency}` + })); + + const orderDate = order.created + ? new Date(order.created).toLocaleString(lang === "sr" ? "sr-RS" : lang === "de" ? "de-DE" : lang === "fr" ? "fr-FR" : "en-US") + : new Date().toLocaleString(); + + const cancellationReason = order.cancellationReason || t.noReason; + + // Customer email data + const customerData = { + ...t, + orderNumber: order.number || order.id, + customerName, + orderDate, + cancellationReason, + items + }; + + // Admin email data (English) + const adminData = { + orderNumber: order.number || order.id, + customerName, + customerEmail: order.userEmail || "", + orderDate, + cancellationReason, + phone: order.shippingAddress?.phone || "", + items + }; + + return { + customerSubject: Handlebars.compile(t.subject)({ orderNumber: order.number || order.id }), + customerHtml: compileCustomerEmail(customerData), + adminSubject: `Order Cancelled #${order.number || order.id}`, + adminHtml: compileAdminEmail(adminData) + }; +} diff --git a/src/lib/email-templates/order-created.ts b/src/lib/email-templates/order-created.ts new file mode 100644 index 0000000..bd26770 --- /dev/null +++ b/src/lib/email-templates/order-created.ts @@ -0,0 +1,287 @@ +import Handlebars from "handlebars"; + +// Translations for Order Created emails +const translations = { + en: { + subject: "Order {{orderNumber}} Confirmed", + title: "Order Confirmed", + greeting: "Dear {{customerName}},", + body: "Thank you for your order! We have received it and it is now being processed.", + orderLabel: "Order:", + dateLabel: "Date:", + statusLabel: "Status:", + itemsLabel: "Items", + subtotalLabel: "Subtotal:", + shippingLabel: "Shipping:", + taxLabel: "Tax:", + totalLabel: "Total:", + shippingAddressLabel: "Shipping Address", + phoneLabel: "Phone:", + phoneNotProvided: "Not provided", + viewOrderButton: "View Order", + thankYou: "Thank you!", + companyName: "ManoonOils", + footerText: "support@manoonoils.com" + }, + sr: { + subject: "Porudžbina {{orderNumber}} Potvrđena", + title: "Porudžbina Potvrđena", + greeting: "Poštovani {{customerName}},", + body: "Hvala vam na porudžbini! Primili smo je i sada je obrađujemo.", + orderLabel: "Porudžbina:", + dateLabel: "Datum:", + statusLabel: "Status:", + itemsLabel: "Artikli", + subtotalLabel: "Međuzbir:", + shippingLabel: "Dostava:", + taxLabel: "Porez:", + totalLabel: "Ukupno:", + shippingAddressLabel: "Adresa za Dostavu", + phoneLabel: "Telefon:", + phoneNotProvided: "Nije navedeno", + viewOrderButton: "Pogledaj Porudžbinu", + thankYou: "Hvala vam!", + companyName: "ManoonOils", + footerText: "support@manoonoils.com" + }, + de: { + subject: "Bestellung {{orderNumber}} Bestätigt", + title: "Bestellung Bestätigt", + greeting: "Sehr geehrte/r {{customerName}},", + body: "Vielen Dank für Ihre Bestellung! Wir haben sie erhalten und bearbeiten sie jetzt.", + orderLabel: "Bestellung:", + dateLabel: "Datum:", + statusLabel: "Status:", + itemsLabel: "Artikel", + subtotalLabel: "Zwischensumme:", + shippingLabel: "Versand:", + taxLabel: "Steuer:", + totalLabel: "Gesamt:", + shippingAddressLabel: "Lieferadresse", + phoneLabel: "Telefon:", + phoneNotProvided: "Nicht angegeben", + viewOrderButton: "Bestellung Ansehen", + thankYou: "Vielen Dank!", + companyName: "ManoonOils", + footerText: "support@manoonoils.com" + }, + fr: { + subject: "Commande {{orderNumber}} Confirmée", + title: "Commande Confirmée", + greeting: "Cher/Chère {{customerName}},", + body: "Merci pour votre commande! Nous l'avons reçue et elle est en cours de traitement.", + orderLabel: "Commande:", + dateLabel: "Date:", + statusLabel: "Statut:", + itemsLabel: "Articles", + subtotalLabel: "Sous-total:", + shippingLabel: "Livraison:", + taxLabel: "Taxe:", + totalLabel: "Total:", + shippingAddressLabel: "Adresse de Livraison", + phoneLabel: "Téléphone:", + phoneNotProvided: "Non fourni", + viewOrderButton: "Voir la Commande", + thankYou: "Merci!", + companyName: "ManoonOils", + footerText: "support@manoonoils.com" + } +}; + +// Customer email template +const customerEmailTemplate = ` + + + + + + +
+
+ {{companyName}} +
+ +

{{title}}

+ +

{{greeting}}

+

{{body}}

+ +
+

{{orderLabel}} {{orderNumber}}

+

{{dateLabel}} {{orderDate}}

+

{{statusLabel}} {{orderStatus}}

+
+ +

{{itemsLabel}}

+
+ {{#each items}} +

{{quantity}}x {{name}} - {{price}}

+ {{/each}} +
+ +
+

{{subtotalLabel}} {{subtotal}}

+

{{shippingLabel}} {{shipping}}

+

{{taxLabel}} {{tax}}

+
+

{{totalLabel}} {{total}}

+
+ + {{#if shippingAddress}} +
+

{{shippingAddressLabel}}

+

{{shippingAddress}}

+

{{phoneLabel}} {{#if phone}}{{phone}}{{else}}{{phoneNotProvided}}{{/if}}

+
+ {{/if}} + +
+ {{viewOrderButton}} +
+ +

{{footerText}}

+

{{thankYou}}

+ +
+

{{companyName}}

+
+
+ + +`; + +// Admin email template +const adminEmailTemplate = ` + + + + + + +
+

{{adminTitle}}

+ +
+

{{adminOrderLabel}} #{{orderNumber}}

+

{{dateLabel}} {{orderDate}}

+

{{statusLabel}} {{orderStatus}}

+

{{paymentLabel}} {{paymentMethod}}

+
+ +
+

{{customerLabel}}

+

{{nameLabel}} {{customerName}}

+

{{emailLabel}} {{customerEmail}}

+

{{phoneLabel}} {{#if phone}}{{phone}}{{else}}{{phoneNotProvided}}{{/if}}

+
+ +

{{itemsLabel}}

+
+ {{#each items}} +

{{quantity}}x {{name}} - {{price}}

+ {{/each}} +
+ +
+

{{subtotalLabel}} {{subtotal}}

+

{{shippingLabel}} {{shipping}}

+

{{taxLabel}} {{tax}}

+
+

{{totalLabel}} {{total}}

+
+ + {{#if shippingAddress}} +
+

{{shippingAddressLabel}}

+

{{shippingAddress}}

+
+ {{/if}} + +
+ Dashboard +
+
+ + +`; + +// Compile templates +const compileCustomerEmail = Handlebars.compile(customerEmailTemplate); +const compileAdminEmail = Handlebars.compile(adminEmailTemplate); + +// Admin translations (always in English) +const adminTranslations = { + adminTitle: "New Order! 🎉", + adminOrderLabel: "Order", + customerLabel: "Customer", + paymentLabel: "Payment:", + paymentMethod: "Card" +}; + +export function getOrderCreatedEmails(order: any) { + // Detect language + const lang = order.languageCode?.toLowerCase() || "en"; + const t = translations[lang as keyof typeof translations] || translations.en; + + // Format data + const customerName = order.shippingAddress?.firstName + ? `${order.shippingAddress.firstName} ${order.shippingAddress.lastName || ""}`.trim() + : order.userEmail?.split("@")[0] || "Customer"; + + const currency = order.total?.gross?.currency || "EUR"; + + const items = (order.lines || []).map((line: any) => ({ + name: line.variant?.product?.name || line.variant?.name || "Product", + quantity: line.quantity || 0, + price: `${line.totalPrice?.gross?.amount?.toFixed(2) || "0.00"} ${currency}` + })); + + const shippingAddress = order.shippingAddress + ? `${order.shippingAddress.firstName || ""} ${order.shippingAddress.lastName || ""}\n${order.shippingAddress.streetAddress1 || ""}\n${order.shippingAddress.city || ""}, ${order.shippingAddress.postalCode || ""}\n${order.shippingAddress.country?.country || ""}` + : ""; + + const orderDate = order.created + ? new Date(order.created).toLocaleString(lang === "sr" ? "sr-RS" : lang === "de" ? "de-DE" : lang === "fr" ? "fr-FR" : "en-US") + : new Date().toLocaleString(); + + // Customer email data + const customerData = { + ...t, + orderNumber: order.number || order.id, + customerName, + orderDate, + orderStatus: order.status || "unfulfilled", + items, + subtotal: `${order.subtotal?.gross?.amount?.toFixed(2) || "0.00"} ${currency}`, + shipping: `${order.shippingPrice?.gross?.amount?.toFixed(2) || "0.00"} ${currency}`, + tax: `${order.total?.tax?.amount?.toFixed(2) || "0.00"} ${currency}`, + total: `${order.total?.gross?.amount?.toFixed(2) || "0.00"} ${currency}`, + shippingAddress, + phone: order.shippingAddress?.phone || "" + }; + + // Admin email data (English) + const adminData = { + ...t, + ...adminTranslations, + orderNumber: order.number || order.id, + customerName, + customerEmail: order.userEmail || "", + orderDate, + orderStatus: order.status || "unfulfilled", + items, + subtotal: `${order.subtotal?.gross?.amount?.toFixed(2) || "0.00"} ${currency}`, + shipping: `${order.shippingPrice?.gross?.amount?.toFixed(2) || "0.00"} ${currency}`, + tax: `${order.total?.tax?.amount?.toFixed(2) || "0.00"} ${currency}`, + total: `${order.total?.gross?.amount?.toFixed(2) || "0.00"} ${currency}`, + shippingAddress, + phone: order.shippingAddress?.phone || "" + }; + + return { + customerSubject: Handlebars.compile(t.subject)({ orderNumber: order.number || order.id }), + customerHtml: compileCustomerEmail(customerData), + adminSubject: `New Order! 🎉 #${order.number || order.id}`, + adminHtml: compileAdminEmail(adminData) + }; +} diff --git a/src/lib/email-templates/order-shipped.ts b/src/lib/email-templates/order-shipped.ts new file mode 100644 index 0000000..0128270 --- /dev/null +++ b/src/lib/email-templates/order-shipped.ts @@ -0,0 +1,214 @@ +import Handlebars from "handlebars"; + +// Translations for Order Shipped emails +const translations = { + en: { + subject: "Order {{orderNumber}} Has Shipped!", + title: "Your Order Has Shipped!", + greeting: "Dear {{customerName}},", + body: "Great news! Your order has been shipped and is on its way.", + orderLabel: "Order:", + dateLabel: "Date:", + trackingLabel: "Tracking Number:", + itemsLabel: "Items", + trackButton: "Track Your Order", + thankYou: "Thank you!", + companyName: "ManoonOils", + footerText: "support@manoonoils.com" + }, + sr: { + subject: "Porudžbina {{orderNumber}} Poslata!", + title: "Vaša Porudžbina je Poslata!", + greeting: "Poštovani {{customerName}},", + body: "Odlične vesti! Vaša porudžbina je poslata i na putu je.", + orderLabel: "Porudžbina:", + dateLabel: "Datum:", + trackingLabel: "Broj Za Praćenje:", + itemsLabel: "Artikli", + trackButton: "Pratite Porudžbinu", + thankYou: "Hvala vam!", + companyName: "ManoonOils", + footerText: "support@manoonoils.com" + }, + de: { + subject: "Bestellung {{orderNumber}} Wurde Versendet!", + title: "Ihre Bestellung Wurde Versendet!", + greeting: "Sehr geehrte/r {{customerName}},", + body: "Tolle Neuigkeiten! Ihre Bestellung wurde versendet und ist unterwegs.", + orderLabel: "Bestellung:", + dateLabel: "Datum:", + trackingLabel: "Sendungsnummer:", + itemsLabel: "Artikel", + trackButton: "Bestellung Verfolgen", + thankYou: "Vielen Dank!", + companyName: "ManoonOils", + footerText: "support@manoonoils.com" + }, + fr: { + subject: "Commande {{orderNumber}} Expédiée!", + title: "Votre Commande a été Expédiée!", + greeting: "Cher/Chère {{customerName}},", + body: "Bonne nouvelle! Votre commande a été expédiée et est en route.", + orderLabel: "Commande:", + dateLabel: "Date:", + trackingLabel: "Numéro de Suivi:", + itemsLabel: "Articles", + trackButton: "Suivre la Commande", + thankYou: "Merci!", + companyName: "ManoonOils", + footerText: "support@manoonoils.com" + } +}; + +// Customer email template +const customerEmailTemplate = ` + + + + + + +
+
+ {{companyName}} +
+ +

{{title}}

+ +

{{greeting}}

+

{{body}}

+ +
+

{{orderLabel}} {{orderNumber}}

+

{{dateLabel}} {{orderDate}}

+ {{#if trackingNumber}} +

{{trackingLabel}} {{trackingNumber}}

+ {{/if}} +
+ +

{{itemsLabel}}

+
+ {{#each items}} +

{{quantity}}x {{name}} - {{price}}

+ {{/each}} +
+ + {{#if trackingUrl}} +
+ {{trackButton}} +
+ {{/if}} + +

{{footerText}}

+

{{thankYou}}

+ +
+

{{companyName}}

+
+
+ + +`; + +// Admin email template +const adminEmailTemplate = ` + + + + + + +
+

Order Shipped

+ +
+

Order #{{orderNumber}}

+

Date: {{orderDate}}

+ {{#if trackingNumber}} +

Tracking: {{trackingNumber}}

+ {{/if}} +
+ +
+

Customer

+

Name: {{customerName}}

+

Email: {{customerEmail}}

+

Phone: {{#if phone}}{{phone}}{{else}}Not provided{{/if}}

+
+ +

Items

+
+ {{#each items}} +

{{quantity}}x {{name}} - {{price}}

+ {{/each}} +
+ +
+ Dashboard +
+
+ + +`; + +// Compile templates +const compileCustomerEmail = Handlebars.compile(customerEmailTemplate); +const compileAdminEmail = Handlebars.compile(adminEmailTemplate); + +export function getOrderShippedEmails(order: any, fulfillment?: any) { + // Detect language + const lang = order.languageCode?.toLowerCase() || "en"; + const t = translations[lang as keyof typeof translations] || translations.en; + + // Format data + const customerName = order.shippingAddress?.firstName + ? `${order.shippingAddress.firstName} ${order.shippingAddress.lastName || ""}`.trim() + : order.userEmail?.split("@")[0] || "Customer"; + + const currency = order.total?.gross?.currency || "EUR"; + + const items = (order.lines || []).map((line: any) => ({ + name: line.variant?.product?.name || line.variant?.name || "Product", + quantity: line.quantity || 0, + price: `${line.totalPrice?.gross?.amount?.toFixed(2) || "0.00"} ${currency}` + })); + + const orderDate = order.created + ? new Date(order.created).toLocaleString(lang === "sr" ? "sr-RS" : lang === "de" ? "de-DE" : lang === "fr" ? "fr-FR" : "en-US") + : new Date().toLocaleString(); + + // Get tracking info from fulfillment + const trackingNumber = fulfillment?.trackingNumber || ""; + const trackingUrl = trackingNumber + ? `https://track.manoonoils.com/${trackingNumber}` + : "https://manoonoils.com"; + + // Customer email data + const customerData = { + ...t, + orderNumber: order.number || order.id, + customerName, + orderDate, + trackingNumber, + trackingUrl, + items + }; + + // Admin email data (English) + const adminData = { + orderNumber: order.number || order.id, + customerName, + customerEmail: order.userEmail || "", + orderDate, + trackingNumber, + phone: order.shippingAddress?.phone || "", + items + }; + + return { + customerSubject: Handlebars.compile(t.subject)({ orderNumber: order.number || order.id }), + customerHtml: compileCustomerEmail(customerData), + adminSubject: `Order Shipped #${order.number || order.id}`, + adminHtml: compileAdminEmail(adminData) + }; +} diff --git a/src/pages/api/webhooks/order-cancelled.ts b/src/pages/api/webhooks/order-cancelled.ts index 61dfe07..1c4ad24 100644 --- a/src/pages/api/webhooks/order-cancelled.ts +++ b/src/pages/api/webhooks/order-cancelled.ts @@ -4,9 +4,12 @@ import { OrderCancelledWebhookPayloadFragment, } from "@/generated/graphql"; import { saleorApp } from "@/saleor-app"; +import { getOrderCancelledEmails } from "@/lib/email-templates"; +import { Resend } from "resend"; -// N8N webhook URL for Order Cancelled -const N8N_WEBHOOK_URL = "https://n8n.nodecrew.me/webhook/saleor-order-cancelled"; +const resend = new Resend(process.env.RESEND_API_KEY); + +const ADMIN_EMAILS = ["me@hytham.me", "tamara@hytham.me"]; export const orderCancelledWebhook = new SaleorAsyncWebhook({ name: "Order Cancelled in Saleor", @@ -17,29 +20,43 @@ export const orderCancelledWebhook = new SaleorAsyncWebhook { - const { payload, event, authData } = ctx; + const { payload } = ctx; + const order = payload.order; + + if (!order) { + console.error("No order data in webhook payload"); + return res.status(200).end(); + } - console.log(`Order cancelled: ${payload.order?.number} for ${payload.order?.userEmail}`); - console.log(`Forwarding to N8N: ${N8N_WEBHOOK_URL}`); + console.log(`Order cancelled: ${order.number} for ${order.userEmail} (lang: ${order.languageCode})`); try { - // Forward to N8N - const response = await fetch(N8N_WEBHOOK_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-saleor-event": "order.cancelled", - }, - body: JSON.stringify(payload), - }); + // Get email templates + const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderCancelledEmails(order); - if (!response.ok) { - console.error(`N8N returned ${response.status}: ${await response.text()}`); - } else { - console.log(`Successfully forwarded to N8N: ${response.status}`); + // Send customer email + if (order.userEmail) { + const customerResult = await resend.emails.send({ + from: "Manoon Oils ", + to: order.userEmail, + subject: customerSubject, + html: customerHtml, + }); + console.log(`Customer cancelled email sent: ${customerResult.data?.id || 'failed'}`); + } + + // Send admin emails + for (const adminEmail of ADMIN_EMAILS) { + const adminResult = await resend.emails.send({ + from: "Manoon Oils ", + to: adminEmail, + subject: adminSubject, + html: adminHtml, + }); + console.log(`Admin cancelled email sent to ${adminEmail}: ${adminResult.data?.id || 'failed'}`); } } catch (error) { - console.error(`Failed to forward to N8N:`, error); + console.error("Failed to send cancelled emails:", error); } return res.status(200).end(); diff --git a/src/pages/api/webhooks/order-created.ts b/src/pages/api/webhooks/order-created.ts index cfe5e24..26aa8a1 100644 --- a/src/pages/api/webhooks/order-created.ts +++ b/src/pages/api/webhooks/order-created.ts @@ -4,9 +4,12 @@ import { OrderCreatedWebhookPayloadFragment, } from "@/generated/graphql"; import { saleorApp } from "@/saleor-app"; +import { getOrderCreatedEmails } from "@/lib/email-templates"; +import { Resend } from "resend"; -// N8N webhook URL for Order Created -const N8N_WEBHOOK_URL = "https://n8n.nodecrew.me/webhook/saleor-order-created"; +const resend = new Resend(process.env.RESEND_API_KEY); + +const ADMIN_EMAILS = ["me@hytham.me", "tamara@hytham.me"]; export const orderCreatedWebhook = new SaleorAsyncWebhook({ name: "Order Created in Saleor", @@ -17,29 +20,43 @@ export const orderCreatedWebhook = new SaleorAsyncWebhook { - const { payload, event, authData } = ctx; + const { payload } = ctx; + const order = payload.order; + + if (!order) { + console.error("No order data in webhook payload"); + return res.status(200).end(); + } - console.log(`Order created: ${payload.order?.number} for ${payload.order?.userEmail}`); - console.log(`Forwarding to N8N: ${N8N_WEBHOOK_URL}`); + console.log(`Order created: ${order.number} for ${order.userEmail} (lang: ${order.languageCode})`); try { - // Forward to N8N - const response = await fetch(N8N_WEBHOOK_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-saleor-event": "order.created", - }, - body: JSON.stringify(payload), - }); + // Get email templates + const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderCreatedEmails(order); - if (!response.ok) { - console.error(`N8N returned ${response.status}: ${await response.text()}`); - } else { - console.log(`Successfully forwarded to N8N: ${response.status}`); + // Send customer email + if (order.userEmail) { + const customerResult = await resend.emails.send({ + from: "Manoon Oils ", + to: order.userEmail, + subject: customerSubject, + html: customerHtml, + }); + console.log(`Customer email sent: ${customerResult.data?.id || 'failed'}`); + } + + // Send admin emails + for (const adminEmail of ADMIN_EMAILS) { + const adminResult = await resend.emails.send({ + from: "Manoon Oils ", + to: adminEmail, + subject: adminSubject, + html: adminHtml, + }); + console.log(`Admin email sent to ${adminEmail}: ${adminResult.data?.id || 'failed'}`); } } catch (error) { - console.error(`Failed to forward to N8N:`, error); + console.error("Failed to send emails:", error); } return res.status(200).end(); diff --git a/src/pages/api/webhooks/order-fulfilled.ts b/src/pages/api/webhooks/order-fulfilled.ts index ecaf0bb..c8a80bd 100644 --- a/src/pages/api/webhooks/order-fulfilled.ts +++ b/src/pages/api/webhooks/order-fulfilled.ts @@ -4,9 +4,12 @@ import { OrderFulfilledWebhookPayloadFragment, } from "@/generated/graphql"; import { saleorApp } from "@/saleor-app"; +import { getOrderShippedEmails } from "@/lib/email-templates"; +import { Resend } from "resend"; -// N8N webhook URL for Order Fulfilled/Shipped -const N8N_WEBHOOK_URL = "https://n8n.nodecrew.me/webhook/saleor-order-shipped"; +const resend = new Resend(process.env.RESEND_API_KEY); + +const ADMIN_EMAILS = ["me@hytham.me", "tamara@hytham.me"]; export const orderFulfilledWebhook = new SaleorAsyncWebhook({ name: "Order Fulfilled in Saleor", @@ -17,29 +20,47 @@ export const orderFulfilledWebhook = new SaleorAsyncWebhook { - const { payload, event, authData } = ctx; + const { payload } = ctx; + const order = payload.order; + + if (!order) { + console.error("No order data in webhook payload"); + return res.status(200).end(); + } - console.log(`Order fulfilled: ${payload.order?.number} for ${payload.order?.userEmail}`); - console.log(`Forwarding to N8N: ${N8N_WEBHOOK_URL}`); + // Get the first fulfillment with tracking info + const fulfillment = (order as any).fulfillments?.[0]; + const trackingNumber = fulfillment?.trackingNumber || ""; + + console.log(`Order fulfilled: ${order.number} for ${order.userEmail} (lang: ${order.languageCode}, tracking: ${trackingNumber})`); try { - // Forward to N8N - const response = await fetch(N8N_WEBHOOK_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-saleor-event": "order.fulfilled", - }, - body: JSON.stringify(payload), - }); + // Get email templates + const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderShippedEmails(order, fulfillment); - if (!response.ok) { - console.error(`N8N returned ${response.status}: ${await response.text()}`); - } else { - console.log(`Successfully forwarded to N8N: ${response.status}`); + // Send customer email + if (order.userEmail) { + const customerResult = await resend.emails.send({ + from: "Manoon Oils ", + to: order.userEmail, + subject: customerSubject, + html: customerHtml, + }); + console.log(`Customer shipped email sent: ${customerResult.data?.id || 'failed'}`); + } + + // Send admin emails + for (const adminEmail of ADMIN_EMAILS) { + const adminResult = await resend.emails.send({ + from: "Manoon Oils ", + to: adminEmail, + subject: adminSubject, + html: adminHtml, + }); + console.log(`Admin shipped email sent to ${adminEmail}: ${adminResult.data?.id || 'failed'}`); } } catch (error) { - console.error(`Failed to forward to N8N:`, error); + console.error("Failed to send shipped emails:", error); } return res.status(200).end();