6 Commits

Author SHA1 Message Date
Gitea Actions ebe97f6714 ci: add Gitea Actions workflow with BuildKit 2026-04-05 08:17:40 +00:00
Unchained 6492619cbc refactor(email): abstract email system with proper architecture
- Create service layer for email generation (PaymentMethodService, OrderTransformerService, EmailBuilderService)
- Extract all hardcoded values to configuration files
- Add proper TypeScript types for all email data structures
- Update GraphQL subscription to include transactions and events
- Refactor order-created, order-cancelled templates to use new architecture
- Payment method now dynamically detected from order transactions

This makes the email system:
- Testable: Each service can be unit tested independently
- Configurable: All values in config.ts, no hardcoding
- Extensible: Easy to add new languages or payment methods
- Maintainable: Clear separation of concerns
2026-03-29 17:33:15 +02:00
Unchained 28b864ecdb docs: add comprehensive roadmap for email service improvements 2026-03-28 17:06:36 +02:00
Unchained 06ff847e7e chore: add .dockerignore to prevent node_modules conflicts during build 2026-03-28 14:17:06 +02:00
Unchained 1089f03ee3 fix: update order-fulfilled and order-cancelled webhooks to forward to N8N
- Update GraphQL fragments to include languageCode and all necessary fields
- Modify order-fulfilled.ts to forward to N8N instead of sending directly
- Modify order-cancelled.ts to forward to N8N instead of sending directly
- Regenerate GraphQL types with full order data
- Enable multi-language emails for shipped and cancelled orders
2026-03-28 13:58:29 +02:00
Unchained 2065b24d7a fix: forward order created webhook to N8N
- Updated to forward to N8N at /webhook/saleor-order-created
- N8N will handle multi-language email sending
2026-03-28 11:31:08 +02:00
22 changed files with 2821 additions and 153 deletions
+40
View File
@@ -0,0 +1,40 @@
# Dependencies
node_modules
.pnpm-store
# Build output
.next
.nuxt
dist
build
# Git
.git
.gitignore
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Testing
coverage
.nyc_output
# Misc
README.md
*.md
+74
View File
@@ -0,0 +1,74 @@
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Trigger BuildKit Build
run: |
echo "Building commit: $GITHUB_SHA"
# Delete old job
kubectl delete job build-core-extensions-action -n gitea --ignore-not-found=true 2>/dev/null || true
# Create build job
cat << EOF | kubectl apply -f -
apiVersion: batch/v1
kind: Job
metadata:
name: build-core-extensions-action
namespace: gitea
spec:
ttlSecondsAfterFinished: 86400
template:
spec:
restartPolicy: Never
initContainers:
- name: clone
image: alpine/git:latest
command:
- sh
- -c
- |
set -e
git clone --depth 1 http://gitea:3000/unchained/saleor-core-extensions.git /workspace
cd /workspace && git checkout $GITHUB_SHA
echo "Building: \$(git rev-parse --short HEAD)"
volumeMounts:
- name: workspace
mountPath: /workspace
containers:
- name: build
image: moby/buildkit:latest
command:
- sh
- -c
- |
set -e
mkdir -p /root/.docker
cp /docker-config/.dockerconfigjson /root/.docker/config.json
echo "Building with BuildKit..."
buildctl --addr tcp://buildkit.gitea.svc.cluster.local:1234 build \ --frontend dockerfile.v0 \ --local context=/workspace \ --local dockerfile=/workspace \ --output type=image,name=ghcr.io/unchainedio/saleor-core-extensions:latest,push=true
echo "Build complete! Triggering Flux..."
kubectl annotate kustomization -n flux-system saleor --overwrite reconcile.fluxcd.io/requestedAt=\"$(date +%s)\"
echo "Done!"
volumeMounts:
- name: workspace
mountPath: /workspace
- name: docker-config
mountPath: /docker-config
readOnly: true
volumes:
- name: workspace
emptyDir: {}
- name: docker-config
secret:
secretName: ghcr-pull-secret
EOF
echo "Build job created!"
kubectl wait --for=condition=complete job/build-core-extensions-action -n gitea --timeout=600s || echo "Build running in background"
+312
View File
@@ -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 <support@mail.manoonoils.com>"
```
**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 <support@mail.manoonoils.com>
### 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*
+185 -20
View File
File diff suppressed because one or more lines are too long
+79 -2
View File
@@ -2,12 +2,89 @@ fragment OrderCancelledWebhookPayload on OrderCancelled {
order {
id
number
created
status
userEmail
languageCode
channel {
slug
}
user {
email
firstName
lastName
}
status
shippingAddress {
firstName
lastName
streetAddress1
streetAddress2
city
postalCode
country {
country
}
phone
}
billingAddress {
firstName
lastName
streetAddress1
streetAddress2
city
postalCode
country {
country
}
phone
}
lines {
id
quantity
unitPrice {
gross {
amount
currency
}
}
totalPrice {
gross {
amount
currency
}
}
variant {
name
sku
product {
name
media {
url
}
}
}
}
subtotal {
gross {
amount
currency
}
}
shippingPrice {
gross {
amount
currency
}
}
total {
gross {
amount
currency
}
}
metadata {
key
value
}
}
}
}
+13
View File
@@ -82,5 +82,18 @@ fragment OrderCreatedWebhookPayload on OrderCreated {
currency
}
}
metadata {
key
value
}
transactions {
id
message
externalUrl
events {
message
type
}
}
}
}
+80 -2
View File
@@ -2,17 +2,95 @@ fragment OrderFulfilledWebhookPayload on OrderFulfilled {
order {
id
number
created
status
userEmail
languageCode
channel {
slug
}
user {
email
firstName
lastName
}
status
shippingAddress {
firstName
lastName
streetAddress1
streetAddress2
city
postalCode
country {
country
}
phone
}
billingAddress {
firstName
lastName
streetAddress1
streetAddress2
city
postalCode
country {
country
}
phone
}
lines {
id
quantity
unitPrice {
gross {
amount
currency
}
}
totalPrice {
gross {
amount
currency
}
}
variant {
name
sku
product {
name
media {
url
}
}
}
}
subtotal {
gross {
amount
currency
}
}
shippingPrice {
gross {
amount
currency
}
}
total {
gross {
amount
currency
}
}
metadata {
key
value
}
fulfillments {
id
status
created
trackingNumber
}
}
}
}
+1
View File
@@ -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",
+733 -27
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
export { getOrderCreatedEmails } from "./order-created";
export { getOrderShippedEmails } from "./order-shipped";
export { getOrderCancelledEmails } from "./order-cancelled";
+166
View File
@@ -0,0 +1,166 @@
/**
* Order Cancelled Email Template
*
* Refactored to use the abstracted email service architecture.
*/
import Handlebars from "handlebars";
import { emailServiceConfig, DEFAULT_LANGUAGE } from "@/lib/email/config";
// Cancelled order specific translations
const cancelledTranslations = {
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: emailServiceConfig.companyName,
footerText: emailServiceConfig.fromEmail,
},
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: emailServiceConfig.companyName,
footerText: emailServiceConfig.fromEmail,
},
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: emailServiceConfig.companyName,
footerText: emailServiceConfig.fromEmail,
},
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: emailServiceConfig.companyName,
footerText: emailServiceConfig.fromEmail,
},
};
// Template
const cancelledEmailTemplate = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f6f6f6;margin:0;padding:20px;">
<div style="max-width:600px;margin:0 auto;background:white;padding:40px 20px;">
<div style="text-align:center;margin-bottom:30px;">
<img src="{{companyLogoUrl}}" width="150" alt="{{companyName}}">
</div>
<h1 style="font-size:24px;font-weight:bold;color:#1a1a1a;margin-bottom:20px;">{{title}}</h1>
<p style="font-size:16px;color:#333;margin-bottom:10px;">{{greeting}}</p>
<p style="font-size:14px;color:#666;margin-bottom:20px;">{{body}}</p>
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{orderLabel}}</strong> {{orderNumber}}</p>
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{dateLabel}}</strong> {{orderDate}}</p>
<p style="font-size:14px;color:#333;margin:0;"><strong>{{reasonLabel}}</strong> {{cancellationReason}}</p>
</div>
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin-bottom:10px;">{{itemsLabel}}</p>
<div style="margin-bottom:20px;">
{{#each items}}
<p style="font-size:14px;color:#333;margin:0 0 4px 0;">{{quantity}}x {{name}} - {{price}}</p>
{{/each}}
</div>
<div style="text-align:center;margin:20px 0;">
<a href="{{storefrontUrl}}" style="display:inline-block;background:#000;color:white;padding:12px 30px;border-radius:4px;text-decoration:none;font-size:14px;font-weight:bold;">{{contactButton}}</a>
</div>
<p style="font-size:14px;color:#666;margin-bottom:10px;">{{footerText}}</p>
<p style="font-size:14px;font-weight:bold;color:#1a1a1a;">{{thankYou}}</p>
<div style="margin-top:40px;padding-top:20px;border-top:1px solid #e0e0e0;text-align:center;color:#666;font-size:12px;">
<p>{{companyName}}</p>
</div>
</div>
</body>
</html>
`;
const compileCancelledEmail = Handlebars.compile(cancelledEmailTemplate);
export function getOrderCancelledEmails(order: any) {
const lang = (order.languageCode?.toLowerCase() || DEFAULT_LANGUAGE) as keyof typeof cancelledTranslations;
const t = cancelledTranslations[lang] || cancelledTranslations[DEFAULT_LANGUAGE];
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 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.metadata?.find((m: { key: string; value: string }) => m.key === "cancellation_reason")?.value || t.noReason;
const greeting = Handlebars.compile(t.greeting)({ customerName });
const subject = Handlebars.compile(t.subject)({ orderNumber: order.number || order.id });
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 emailData = {
...t,
greeting,
orderNumber: order.number || order.id,
orderDate,
cancellationReason,
items,
companyLogoUrl: emailServiceConfig.companyLogoUrl,
storefrontUrl: emailServiceConfig.storefrontUrl,
};
return {
customerSubject: subject,
customerHtml: compileCancelledEmail(emailData),
adminSubject: `Order Cancelled #${order.number || order.id}`,
adminHtml: compileCancelledEmail({
...emailData,
title: `Order Cancelled - #${order.number || order.id}`,
body: `Order has been cancelled. Customer: ${order.userEmail}. Reason: ${cancellationReason}`,
}),
};
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Order Created Email Template
*
* This module is now a thin wrapper around the abstracted email service architecture.
* All hardcoded values have been moved to configuration files.
*
* Architecture:
* - types.ts: Type definitions
* - config.ts: All configurable values (translations, URLs, etc.)
* - services/paymentMethodService.ts: Payment method detection logic
* - services/orderTransformerService.ts: Order data transformation
* - services/emailBuilderService.ts: Email template compilation
*/
import { EmailBuilderService } from "@/lib/email/services/emailBuilderService";
import { emailServiceConfig } from "@/lib/email/config";
/**
* Generate order created emails (customer + admin)
*
* @param order - Saleor order object
* @returns EmailResult with customer and admin email content
*
* @example
* const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderCreatedEmails(order);
*/
export function getOrderCreatedEmails(order: any) {
return EmailBuilderService.buildOrderCreatedEmails(order, {
companyLogoUrl: emailServiceConfig.companyLogoUrl,
companyName: emailServiceConfig.companyName,
storefrontUrl: emailServiceConfig.storefrontUrl,
dashboardUrl: emailServiceConfig.dashboardUrl,
});
}
// Re-export types for consumers
export type { EmailResult } from "@/lib/email/types";
+218
View File
@@ -0,0 +1,218 @@
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 = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f6f6f6;margin:0;padding:20px;">
<div style="max-width:600px;margin:0 auto;background:white;padding:40px 20px;">
<div style="text-align:center;margin-bottom:30px;">
<img src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png" width="150" alt="{{companyName}}">
</div>
<h1 style="font-size:24px;font-weight:bold;color:#1a1a1a;margin-bottom:20px;">{{title}}</h1>
<p style="font-size:16px;color:#333;margin-bottom:10px;">{{greeting}}</p>
<p style="font-size:14px;color:#666;margin-bottom:20px;">{{body}}</p>
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{orderLabel}}</strong> {{orderNumber}}</p>
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{dateLabel}}</strong> {{orderDate}}</p>
{{#if trackingNumber}}
<p style="font-size:14px;color:#333;margin:0;"><strong>{{trackingLabel}}</strong> {{trackingNumber}}</p>
{{/if}}
</div>
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin-bottom:10px;">{{itemsLabel}}</p>
<div style="margin-bottom:20px;">
{{#each items}}
<p style="font-size:14px;color:#333;margin:0 0 4px 0;">{{quantity}}x {{name}} - {{price}}</p>
{{/each}}
</div>
{{#if trackingUrl}}
<div style="text-align:center;margin:20px 0;">
<a href="{{trackingUrl}}" style="display:inline-block;background:#000;color:white;padding:12px 30px;border-radius:4px;text-decoration:none;font-size:14px;font-weight:bold;">{{trackButton}}</a>
</div>
{{/if}}
<p style="font-size:14px;color:#666;margin-bottom:10px;">{{footerText}}</p>
<p style="font-size:14px;font-weight:bold;color:#1a1a1a;">{{thankYou}}</p>
<div style="margin-top:40px;padding-top:20px;border-top:1px solid #e0e0e0;text-align:center;color:#666;font-size:12px;">
<p>{{companyName}}</p>
</div>
</div>
</body>
</html>
`;
// Admin email template
const adminEmailTemplate = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f6f6f6;margin:0;padding:20px;">
<div style="max-width:600px;margin:0 auto;background:white;padding:40px 20px;">
<h1 style="font-size:24px;font-weight:bold;color:#1a1a1a;margin-bottom:20px;">Order Shipped</h1>
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
<h3 style="font-size:16px;font-weight:bold;color:#1a1a1a;margin:0 0 10px 0;">Order #{{orderNumber}}</h3>
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>Date:</strong> {{orderDate}}</p>
{{#if trackingNumber}}
<p style="font-size:14px;color:#333;margin:0;"><strong>Tracking:</strong> {{trackingNumber}}</p>
{{/if}}
</div>
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
<h3 style="font-size:16px;font-weight:bold;color:#1a1a1a;margin:0 0 10px 0;">Customer</h3>
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>Name:</strong> {{customerName}}</p>
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>Email:</strong> {{customerEmail}}</p>
<p style="font-size:14px;color:#333;margin:0;"><strong>Phone:</strong> {{#if phone}}{{phone}}{{else}}Not provided{{/if}}</p>
</div>
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin-bottom:10px;">Items</p>
<div style="margin-bottom:20px;">
{{#each items}}
<p style="font-size:14px;color:#333;margin:0 0 4px 0;">{{quantity}}x {{name}} - {{price}}</p>
{{/each}}
</div>
<div style="text-align:center;margin:20px 0;">
<a href="https://dashboard.manoonoils.com/orders" style="display:inline-block;background:#000;color:white;padding:12px 30px;border-radius:4px;text-decoration:none;font-size:14px;font-weight:bold;">Dashboard</a>
</div>
</div>
</body>
</html>
`;
// 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";
// Compile greeting with customer name
const greeting = Handlebars.compile(t.greeting)({ customerName });
// Customer email data
const customerData = {
...t,
greeting,
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)
};
}
+170
View File
@@ -0,0 +1,170 @@
/**
* Email Configuration - All configurable values extracted from hardcoded locations
* This makes the system configurable without changing code
*/
import type {
LanguageCode,
EmailTemplateConfig,
AdminEmailTemplateConfig,
PaymentMethodConfig,
EmailServiceConfig
} from "./types";
// Payment method configurations - easily extendable
export const paymentMethodConfigs: Record<string, PaymentMethodConfig> = {
card: {
id: "card",
label: "Card",
},
cod: {
id: "cod",
label: "Cash on Delivery",
},
paypal: {
id: "paypal",
label: "PayPal",
},
bank_transfer: {
id: "bank_transfer",
label: "Bank Transfer",
},
unknown: {
id: "unknown",
label: "Unknown",
},
};
// Customer email translations - configuration driven
export const emailTranslations: Record<LanguageCode, EmailTemplateConfig> = {
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",
},
};
// Admin email translations - always in English but configurable
export const adminEmailTranslations: AdminEmailTemplateConfig = {
adminTitle: "New Order! 🎉",
adminOrderLabel: "Order",
customerLabel: "Customer",
nameLabel: "Name:",
emailLabel: "Email:",
dashboardButton: "Dashboard",
};
// Default payment label for admin emails
export const adminPaymentConfig = {
paymentLabel: "Payment:",
};
// Email service configuration - loaded from environment or defaults
export const emailServiceConfig: EmailServiceConfig = {
fromEmail: process.env.EMAIL_FROM_ADDRESS || "support@mail.manoonoils.com",
fromName: process.env.EMAIL_FROM_NAME || "Manoon Oils",
adminEmails: process.env.ADMIN_EMAILS?.split(",") || [
"me@hytham.me",
"tamara@hytham.me",
],
companyName: process.env.COMPANY_NAME || "ManoonOils",
companyLogoUrl:
process.env.COMPANY_LOGO_URL ||
"https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png",
dashboardUrl: process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com",
storefrontUrl: process.env.STOREFRONT_URL || "https://manoonoils.com",
trackingUrl: process.env.TRACKING_URL || "https://track.manoonoils.com",
};
// Locale configurations for date formatting
export const localeConfigs: Record<LanguageCode, string> = {
en: "en-US",
sr: "sr-RS",
de: "de-DE",
fr: "fr-FR",
};
// Default fallback language
export const DEFAULT_LANGUAGE: LanguageCode = "en";
// Default currency
export const DEFAULT_CURRENCY = "EUR";
+14
View File
@@ -0,0 +1,14 @@
/**
* Email Module - Centralized exports for the email system
*/
// Types
export * from "./types";
// Configuration
export * from "./config";
// Services
export { PaymentMethodService } from "./services/paymentMethodService";
export { OrderTransformerService } from "./services/orderTransformerService";
export { EmailBuilderService } from "./services/emailBuilderService";
@@ -0,0 +1,197 @@
/**
* Email Builder Service - Compiles email templates with data
* Handles template compilation and rendering
*/
import Handlebars from "handlebars";
import type { SaleorOrder, EmailResult } from "../types";
import { OrderTransformerService } from "./orderTransformerService";
import {
emailTranslations,
adminEmailTranslations,
adminPaymentConfig,
DEFAULT_LANGUAGE,
} from "../config";
// Customer email template
const customerEmailTemplate = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f6f6f6;margin:0;padding:20px;">
<div style="max-width:600px;margin:0 auto;background:white;padding:40px 20px;">
<div style="text-align:center;margin-bottom:30px;">
<img src="{{companyLogoUrl}}" width="150" alt="{{companyName}}">
</div>
<h1 style="font-size:24px;font-weight:bold;color:#1a1a1a;margin-bottom:20px;">{{title}}</h1>
<p style="font-size:16px;color:#333;margin-bottom:10px;">{{greeting}}</p>
<p style="font-size:14px;color:#666;margin-bottom:20px;">{{body}}</p>
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{orderLabel}}</strong> {{orderNumber}}</p>
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{dateLabel}}</strong> {{orderDate}}</p>
<p style="font-size:14px;color:#333;margin:0;"><strong>{{statusLabel}}</strong> {{orderStatus}}</p>
</div>
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin-bottom:10px;">{{itemsLabel}}</p>
<div style="margin-bottom:20px;">
{{#each items}}
<p style="font-size:14px;color:#333;margin:0 0 4px 0;">{{quantity}}x {{name}} - {{price}}</p>
{{/each}}
</div>
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{subtotalLabel}}</strong> {{subtotal}}</p>
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{shippingLabel}}</strong> {{shipping}}</p>
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{taxLabel}}</strong> {{tax}}</p>
<hr style="border-color:#e0e0e0;margin:10px 0;">
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin:0;"><strong>{{totalLabel}}</strong> {{total}}</p>
</div>
{{#if shippingAddress}}
<div style="margin-bottom:20px;">
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin-bottom:10px;">{{shippingAddressLabel}}</p>
<p style="font-size:14px;color:#666;margin:0;white-space:pre-line;">{{shippingAddress}}</p>
<p style="font-size:14px;color:#666;margin:8px 0 0 0;"><strong>{{phoneLabel}}</strong> {{#if phone}}{{phone}}{{else}}{{phoneNotProvided}}{{/if}}</p>
</div>
{{/if}}
<div style="text-align:center;margin:20px 0;">
<a href="{{storefrontUrl}}" style="display:inline-block;background:#000;color:white;padding:12px 30px;border-radius:4px;text-decoration:none;font-size:14px;font-weight:bold;">{{viewOrderButton}}</a>
</div>
<p style="font-size:14px;color:#666;margin-bottom:10px;">{{footerText}}</p>
<p style="font-size:14px;font-weight:bold;color:#1a1a1a;">{{thankYou}}</p>
<div style="margin-top:40px;padding-top:20px;border-top:1px solid #e0e0e0;text-align:center;color:#666;font-size:12px;">
<p>{{companyName}}</p>
</div>
</div>
</body>
</html>
`;
// Admin email template
const adminEmailTemplate = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
</head>
<body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f6f6f6;margin:0;padding:20px;">
<div style="max-width:600px;margin:0 auto;background:white;padding:40px 20px;">
<h1 style="font-size:24px;font-weight:bold;color:#1a1a1a;margin-bottom:20px;">{{adminTitle}}</h1>
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
<h3 style="font-size:16px;font-weight:bold;color:#1a1a1a;margin:0 0 10px 0;">{{adminOrderLabel}} #{{orderNumber}}</h3>
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{dateLabel}}</strong> {{orderDate}}</p>
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{statusLabel}}</strong> {{orderStatus}}</p>
<p style="font-size:14px;color:#333;margin:0;"><strong>{{paymentLabel}}</strong> {{paymentMethod}}</p>
</div>
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
<h3 style="font-size:16px;font-weight:bold;color:#1a1a1a;margin:0 0 10px 0;">{{customerLabel}}</h3>
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{nameLabel}}</strong> {{customerName}}</p>
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{emailLabel}}</strong> {{customerEmail}}</p>
<p style="font-size:14px;color:#333;margin:0;"><strong>{{phoneLabel}}</strong> {{#if phone}}{{phone}}{{else}}{{phoneNotProvided}}{{/if}}</p>
</div>
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin-bottom:10px;">{{itemsLabel}}</p>
<div style="margin-bottom:20px;">
{{#each items}}
<p style="font-size:14px;color:#333;margin:0 0 4px 0;">{{quantity}}x {{name}} - {{price}}</p>
{{/each}}
</div>
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{subtotalLabel}}</strong> {{subtotal}}</p>
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{shippingLabel}}</strong> {{shipping}}</p>
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{taxLabel}}</strong> {{tax}}</p>
<hr style="border-color:#e0e0e0;margin:10px 0;">
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin:0;"><strong>{{totalLabel}}</strong> {{total}}</p>
</div>
{{#if shippingAddress}}
<div style="margin-bottom:20px;">
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin-bottom:10px;">{{shippingAddressLabel}}</p>
<p style="font-size:14px;color:#666;margin:0;white-space:pre-line;">{{shippingAddress}}</p>
</div>
{{/if}}
<div style="text-align:center;margin:20px 0;">
<a href="{{dashboardUrl}}" style="display:inline-block;background:#000;color:white;padding:12px 30px;border-radius:4px;text-decoration:none;font-size:14px;font-weight:bold;">{{dashboardButton}}</a>
</div>
</div>
</body>
</html>
`;
export class EmailBuilderService {
private static compileCustomerEmail = Handlebars.compile(customerEmailTemplate);
private static compileAdminEmail = Handlebars.compile(adminEmailTemplate);
/**
* Build order created emails (customer + admin)
*/
static buildOrderCreatedEmails(
order: SaleorOrder,
config: {
companyLogoUrl: string;
companyName: string;
storefrontUrl: string;
dashboardUrl: string;
}
): EmailResult {
const languageCode = OrderTransformerService.getLanguageCode(order);
const translations = emailTranslations[languageCode] || emailTranslations[DEFAULT_LANGUAGE];
// Compile greeting and subject with Handlebars
const greeting = Handlebars.compile(translations.greeting)({
customerName: OrderTransformerService.extractCustomerName(order),
});
const subject = Handlebars.compile(translations.subject)({
orderNumber: order.number || order.id,
});
// Build customer email data
const customerData = {
...OrderTransformerService.transformToEmailData(order, translations),
greeting,
subject,
companyLogoUrl: config.companyLogoUrl,
storefrontUrl: config.storefrontUrl,
};
// Build admin email data
const adminTranslationsRecord: Record<string, string> = {
adminTitle: adminEmailTranslations.adminTitle,
adminOrderLabel: adminEmailTranslations.adminOrderLabel,
customerLabel: adminEmailTranslations.customerLabel,
nameLabel: adminEmailTranslations.nameLabel,
emailLabel: adminEmailTranslations.emailLabel,
dashboardButton: adminEmailTranslations.dashboardButton,
};
const adminData = {
...OrderTransformerService.transformToAdminEmailData(
order,
translations,
adminTranslationsRecord
),
...adminPaymentConfig,
dashboardUrl: config.dashboardUrl,
companyLogoUrl: config.companyLogoUrl,
};
return {
customerSubject: subject,
customerHtml: this.compileCustomerEmail(customerData),
adminSubject: `${adminEmailTranslations.adminTitle} #${order.number || order.id}`,
adminHtml: this.compileAdminEmail(adminData),
};
}
}
@@ -0,0 +1,167 @@
/**
* Order Transformer Service - Transforms Saleor order data into normalized email data
* All transformation logic abstracted here instead of inline in templates
*/
import type {
SaleorOrder,
EmailData,
AdminEmailData,
LanguageCode,
OrderLine
} from "../types";
import {
localeConfigs,
DEFAULT_LANGUAGE,
DEFAULT_CURRENCY
} from "../config";
import { PaymentMethodService } from "./paymentMethodService";
export class OrderTransformerService {
/**
* Extract and format customer name from order
*/
static extractCustomerName(order: SaleorOrder): string {
if (order.shippingAddress?.firstName) {
return `${order.shippingAddress.firstName} ${order.shippingAddress.lastName || ""}`.trim();
}
if (order.billingAddress?.firstName) {
return `${order.billingAddress.firstName} ${order.billingAddress.lastName || ""}`.trim();
}
if (order.userEmail) {
return order.userEmail.split("@")[0];
}
return "Customer";
}
/**
* Format shipping address for display
*/
static formatShippingAddress(order: SaleorOrder): string {
const address = order.shippingAddress;
if (!address) return "";
const parts = [
`${address.firstName || ""} ${address.lastName || ""}`.trim(),
address.streetAddress1,
address.streetAddress2,
`${address.city || ""}${address.postalCode ? `, ${address.postalCode}` : ""}`,
address.country?.country || address.country?.code,
].filter(Boolean);
return parts.join("\n");
}
/**
* Extract phone number with fallback
*/
static extractPhone(order: SaleorOrder): string {
return (
order.shippingAddress?.phone ||
order.billingAddress?.phone ||
""
);
}
/**
* Format order items for email
*/
static formatItems(order: SaleorOrder): Array<{
name: string;
quantity: number;
price: string;
}> {
const currency = order.total?.gross?.currency || DEFAULT_CURRENCY;
return (order.lines || []).map((line: OrderLine) => {
const productName = line.variant?.product?.name || line.variant?.name || "Product";
const amount = line.totalPrice?.gross?.amount?.toFixed(2) || "0.00";
return {
name: productName,
quantity: line.quantity || 0,
price: `${amount} ${currency}`,
};
});
}
/**
* Format money amount with currency
*/
static formatMoney(amount: number | undefined, currency: string): string {
const value = amount?.toFixed(2) || "0.00";
return `${value} ${currency}`;
}
/**
* Format order date for display
*/
static formatOrderDate(order: SaleorOrder, languageCode: LanguageCode): string {
const date = order.created ? new Date(order.created) : new Date();
const locale = localeConfigs[languageCode] || localeConfigs[DEFAULT_LANGUAGE];
return date.toLocaleString(locale);
}
/**
* Get language code with fallback
*/
static getLanguageCode(order: SaleorOrder): LanguageCode {
const lang = order.languageCode?.toLowerCase() as LanguageCode;
if (["en", "sr", "de", "fr"].includes(lang)) {
return lang;
}
return DEFAULT_LANGUAGE;
}
/**
* Transform order into email data structure
*/
static transformToEmailData(
order: SaleorOrder,
translations: Record<string, any>
): EmailData {
const languageCode = this.getLanguageCode(order);
const currency = order.total?.gross?.currency || DEFAULT_CURRENCY;
const customerName = this.extractCustomerName(order);
const paymentMethod = PaymentMethodService.detectPaymentMethod(order);
return {
...translations,
orderNumber: order.number || order.id,
customerName,
greeting: "", // Will be compiled by email builder
subject: "", // Will be compiled by email builder
orderDate: this.formatOrderDate(order, languageCode),
orderStatus: order.status || "unfulfilled",
items: this.formatItems(order),
subtotal: this.formatMoney(order.subtotal?.gross?.amount, currency),
shipping: this.formatMoney(order.shippingPrice?.gross?.amount, currency),
tax: this.formatMoney(order.total?.tax?.amount, currency),
total: this.formatMoney(order.total?.gross?.amount, currency),
shippingAddress: this.formatShippingAddress(order),
phone: this.extractPhone(order),
paymentMethod: PaymentMethodService.getPaymentLabel(paymentMethod),
paymentLabel: "Payment:", // Can be moved to config if needed per language
};
}
/**
* Transform order into admin email data structure
*/
static transformToAdminEmailData(
order: SaleorOrder,
translations: Record<string, any>,
adminTranslations: Record<string, string>
): Record<string, any> {
const baseData = this.transformToEmailData(order, translations);
const paymentMethod = PaymentMethodService.detectPaymentMethod(order);
return {
...baseData,
...adminTranslations,
customerEmail: order.userEmail || "",
paymentMethod: PaymentMethodService.getPaymentLabel(paymentMethod),
paymentLabel: "Payment:",
};
}
}
@@ -0,0 +1,71 @@
/**
* Payment Method Service - Detects payment method from order data
* Abstracted logic for determining how an order was paid
*/
import type { SaleorOrder, PaymentMethod } from "../types";
import { paymentMethodConfigs } from "../config";
export class PaymentMethodService {
/**
* Detect payment method from order transactions and metadata
*/
static detectPaymentMethod(order: SaleorOrder): PaymentMethod {
// Check transactions first
if (order.transactions && order.transactions.length > 0) {
const transaction = order.transactions[0];
const message = transaction.message?.toLowerCase() || "";
// Check transaction events for payment type
const event = transaction.events?.[0];
const eventMessage = event?.message?.toLowerCase() || "";
const eventType = event?.type?.toLowerCase() || "";
const combinedText = `${message} ${eventMessage} ${eventType}`;
if (combinedText.includes("cash") || combinedText.includes("cod")) {
return "cod";
}
if (combinedText.includes("card") || combinedText.includes("stripe")) {
return "card";
}
if (combinedText.includes("paypal")) {
return "paypal";
}
if (combinedText.includes("bank") || combinedText.includes("transfer")) {
return "bank_transfer";
}
}
// Check metadata for payment method
if (order.metadata) {
const paymentMeta = order.metadata.find(
(m) => m.key === "payment_method" || m.key === "paymentMethod"
);
if (paymentMeta) {
const value = paymentMeta.value.toLowerCase();
if (value.includes("cash") || value.includes("cod")) return "cod";
if (value.includes("card")) return "card";
if (value.includes("paypal")) return "paypal";
if (value.includes("bank")) return "bank_transfer";
}
}
// Default to unknown if no payment method detected
return "unknown";
}
/**
* Get display label for a payment method
*/
static getPaymentLabel(method: PaymentMethod): string {
return paymentMethodConfigs[method]?.label || paymentMethodConfigs.unknown.label;
}
/**
* Get full payment method configuration
*/
static getPaymentConfig(method: PaymentMethod) {
return paymentMethodConfigs[method] || paymentMethodConfigs.unknown;
}
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Email Types - Type definitions for the email system
* All data structures are strictly typed with no hardcoded values
*/
export type LanguageCode = "en" | "sr" | "de" | "fr";
export type PaymentMethod = "card" | "cod" | "paypal" | "bank_transfer" | "unknown";
export type OrderStatus = "draft" | "unconfirmed" | "unfulfilled" | "partially_fulfilled" | "fulfilled" | "canceled";
export interface Money {
amount: number;
currency: string;
}
export interface Address {
firstName?: string | null;
lastName?: string | null;
streetAddress1?: string | null;
streetAddress2?: string | null;
city?: string | null;
postalCode?: string | null;
country?: {
country?: string | null;
code?: string | null;
} | null;
phone?: string | null;
}
export interface OrderLine {
quantity: number;
variant?: {
name?: string;
product?: {
name?: string;
};
};
totalPrice?: {
gross?: Money;
};
}
export interface Transaction {
id?: string;
message?: string;
externalUrl?: string;
events?: TransactionEvent[];
}
export interface TransactionEvent {
id?: string;
message?: string;
type?: string;
amount?: Money;
}
export interface SaleorOrder {
id: string;
number?: string | null;
created?: string | null;
status?: string | null;
languageCode?: string | null;
userEmail?: string | null;
shippingAddress?: Address | null;
billingAddress?: Address | null;
lines?: OrderLine[] | null;
subtotal?: {
gross?: Money | null;
} | null;
shippingPrice?: {
gross?: Money | null;
} | null;
total?: {
gross?: Money | null;
tax?: Money | null;
} | null;
transactions?: Transaction[] | null;
metadata?: Array<{
key: string;
value: string;
}> | null;
}
export interface PaymentMethodConfig {
id: PaymentMethod;
label: string;
icon?: string;
}
export interface EmailTemplateConfig {
subject: string;
title: string;
greeting: string;
body: string;
orderLabel: string;
dateLabel: string;
statusLabel: string;
itemsLabel: string;
subtotalLabel: string;
shippingLabel: string;
taxLabel: string;
totalLabel: string;
shippingAddressLabel: string;
phoneLabel: string;
phoneNotProvided: string;
viewOrderButton: string;
thankYou: string;
companyName: string;
footerText: string;
}
export interface AdminEmailTemplateConfig {
adminTitle: string;
adminOrderLabel: string;
customerLabel: string;
nameLabel: string;
emailLabel: string;
dashboardButton: string;
}
export interface EmailData {
orderNumber: string;
customerName: string;
greeting: string;
subject: string;
orderDate: string;
orderStatus: string;
items: Array<{
name: string;
quantity: number;
price: string;
}>;
subtotal: string;
shipping: string;
tax: string;
total: string;
shippingAddress: string;
phone: string;
paymentMethod?: string;
paymentLabel?: string;
[key: string]: any;
}
export interface AdminEmailData extends EmailData {
customerEmail: string;
adminTitle: string;
adminOrderLabel: string;
customerLabel: string;
nameLabel: string;
emailLabel: string;
dashboardButton: string;
}
export interface EmailResult {
customerSubject: string;
customerHtml: string;
adminSubject: string;
adminHtml: string;
}
export interface EmailServiceConfig {
fromEmail: string;
fromName: string;
adminEmails: string[];
companyName: string;
companyLogoUrl: string;
dashboardUrl: string;
storefrontUrl: string;
trackingUrl?: string;
}
+30 -25
View File
@@ -4,7 +4,12 @@ import {
OrderCancelledWebhookPayloadFragment,
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
import { sendOrderCancelledEmail, formatPrice } from "@/lib/resend";
import { getOrderCancelledEmails } from "@/lib/email-templates/order-cancelled";
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
const ADMIN_EMAILS = ["me@hytham.me", "tamara@hytham.me"];
export const orderCancelledWebhook = new SaleorAsyncWebhook<OrderCancelledWebhookPayloadFragment>({
name: "Order Cancelled in Saleor",
@@ -15,7 +20,7 @@ export const orderCancelledWebhook = new SaleorAsyncWebhook<OrderCancelledWebhoo
});
export default orderCancelledWebhook.createHandler(async (req, res, ctx) => {
const { payload, event, baseUrl, authData } = ctx;
const { payload } = ctx;
const order = payload.order;
if (!order) {
@@ -23,35 +28,35 @@ export default orderCancelledWebhook.createHandler(async (req, res, ctx) => {
return res.status(200).end();
}
console.log(`Order ${order.number} cancelled for customer: ${order.userEmail}`);
const items = ((order as any).lines || []).map((line: any) => ({
id: line.id,
name: line.variant?.product?.name || "Unknown Product",
quantity: line.quantity,
price: formatPrice(line.totalPrice?.gross?.amount || 0, line.totalPrice?.gross?.currency || "USD"),
}));
const orderData = {
orderId: order.id,
orderNumber: order.number || "Unknown",
customerName: (order as any).shippingAddress?.firstName
? `${(order as any).shippingAddress.firstName} ${(order as any).shippingAddress.lastName || ""}`.trim()
: order.userEmail?.split("@")[0] || "Customer",
items,
total: formatPrice((order as any).total?.gross?.amount || 0, (order as any).total?.gross?.currency || "USD"),
};
console.log(`Order cancelled: ${order.number} for ${order.userEmail} (lang: ${order.languageCode})`);
try {
// Get email templates
const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderCancelledEmails(order);
// Send customer email
if (order.userEmail) {
await sendOrderCancelledEmail({
const customerResult = await resend.emails.send({
from: "Manoon Oils <support@mail.manoonoils.com>",
to: order.userEmail,
orderData,
subject: customerSubject,
html: customerHtml,
});
console.log(`Customer notification sent for cancelled order ${order.number}`);
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 <support@mail.manoonoils.com>",
to: adminEmail,
subject: adminSubject,
html: adminHtml,
});
console.log(`Admin cancelled email sent to ${adminEmail}: ${adminResult.data?.id || 'failed'}`);
}
} catch (error) {
console.error("Failed to send email:", error);
console.error("Failed to send cancelled emails:", error);
}
return res.status(200).end();
@@ -61,4 +66,4 @@ export const config = {
api: {
bodyParser: false,
},
};
};
+29 -54
View File
@@ -4,7 +4,12 @@ import {
OrderCreatedWebhookPayloadFragment,
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
import { sendOrderConfirmationEmail, formatPrice } from "@/lib/resend";
import { getOrderCreatedEmails } from "@/lib/email-templates";
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
const ADMIN_EMAILS = ["me@hytham.me", "tamara@hytham.me"];
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedWebhookPayloadFragment>({
name: "Order Created in Saleor",
@@ -15,7 +20,7 @@ export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedWebhookPay
});
export default orderCreatedWebhook.createHandler(async (req, res, ctx) => {
const { payload, event, baseUrl, authData } = ctx;
const { payload } = ctx;
const order = payload.order;
if (!order) {
@@ -23,65 +28,35 @@ export default orderCreatedWebhook.createHandler(async (req, res, ctx) => {
return res.status(200).end();
}
console.log(`🎉 Order #${order.number} created for customer: ${order.userEmail} (${order.languageCode || "EN"})`);
console.log(`Order created: ${order.number} for ${order.userEmail} (lang: ${order.languageCode})`);
const items = ((order as any).lines || []).map((line: any) => ({
id: line.id,
name: line.variant?.product?.name || "Unknown Product",
quantity: line.quantity,
price: formatPrice(line.totalPrice?.gross?.amount || 0, line.totalPrice?.gross?.currency || "USD"),
}));
const orderData = {
orderId: order.id,
orderNumber: order.number || "Unknown",
customerEmail: order.userEmail || "",
customerName: (order as any).shippingAddress?.firstName
? `${(order as any).shippingAddress.firstName} ${(order as any).shippingAddress.lastName || ""}`.trim()
: order.userEmail?.split("@")[0] || "Customer",
items,
total: formatPrice((order as any).total?.gross?.amount || 0, (order as any).total?.gross?.currency || "USD"),
shippingAddress: (order as any).shippingAddress
? `${(order as any).shippingAddress.firstName || ""} ${(order as any).shippingAddress.lastName || ""}\n${(order as any).shippingAddress.streetAddress1 || ""}\n${(order as any).shippingAddress.postalCode || ""} ${(order as any).shippingAddress.city || ""}\n${(order as any).shippingAddress.country?.country || ""}${(order as any).shippingAddress.phone ? `\nPhone: ${(order as any).shippingAddress.phone}` : ""}`
: undefined,
billingAddress: (order as any).billingAddress
? `${(order as any).billingAddress.firstName || ""} ${(order as any).billingAddress.lastName || ""}\n${(order as any).billingAddress.streetAddress1 || ""}\n${(order as any).billingAddress.postalCode || ""} ${(order as any).billingAddress.city || ""}\n${(order as any).billingAddress.country?.country || ""}${(order as any).billingAddress.phone ? `\nPhone: ${(order as any).billingAddress.phone}` : ""}`
: undefined,
phone: (order as any).shippingAddress?.phone,
};
// Send admin notification
try {
const adminEmails = process.env.ADMIN_EMAILS?.split(",").map(e => e.trim()).filter(e => e) || [];
if (adminEmails.length > 0) {
await sendOrderConfirmationEmail({
to: adminEmails,
orderData,
isAdmin: true,
});
console.log(`✅ Admin notification sent for order #${order.number} to: ${adminEmails.join(", ")}`);
} else {
console.log("⚠️ No admin emails configured, skipping admin notification");
}
} catch (error) {
console.error("❌ Failed to send admin email:", error);
}
// Get email templates
const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderCreatedEmails(order);
// Send customer confirmation
try {
// Send customer email
if (order.userEmail) {
await sendOrderConfirmationEmail({
const customerResult = await resend.emails.send({
from: "Manoon Oils <support@mail.manoonoils.com>",
to: order.userEmail,
orderData,
isAdmin: false,
subject: customerSubject,
html: customerHtml,
});
console.log(`Customer confirmation sent for order #${order.number} to: ${order.userEmail}`);
} else {
console.log("⚠️ No customer email found, skipping customer notification");
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 <support@mail.manoonoils.com>",
to: adminEmail,
subject: adminSubject,
html: adminHtml,
});
console.log(`Admin email sent to ${adminEmail}: ${adminResult.data?.id || 'failed'}`);
}
} catch (error) {
console.error("Failed to send customer email:", error);
console.error("Failed to send emails:", error);
}
return res.status(200).end();
@@ -91,4 +66,4 @@ export const config = {
api: {
bodyParser: false,
},
};
};
+33 -23
View File
@@ -4,7 +4,12 @@ import {
OrderFulfilledWebhookPayloadFragment,
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
import { sendOrderShippedEmail, formatPrice } from "@/lib/resend";
import { getOrderShippedEmails } from "@/lib/email-templates";
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
const ADMIN_EMAILS = ["me@hytham.me", "tamara@hytham.me"];
export const orderFulfilledWebhook = new SaleorAsyncWebhook<OrderFulfilledWebhookPayloadFragment>({
name: "Order Fulfilled in Saleor",
@@ -15,7 +20,7 @@ export const orderFulfilledWebhook = new SaleorAsyncWebhook<OrderFulfilledWebhoo
});
export default orderFulfilledWebhook.createHandler(async (req, res, ctx) => {
const { payload, event, baseUrl, authData } = ctx;
const { payload } = ctx;
const order = payload.order;
if (!order) {
@@ -23,34 +28,39 @@ export default orderFulfilledWebhook.createHandler(async (req, res, ctx) => {
return res.status(200).end();
}
console.log(`Order ${order.number} fulfilled for customer: ${order.userEmail}`);
// Get the first fulfillment with tracking info
const fulfillment = (order as any).fulfillments?.[0];
const trackingNumber = fulfillment?.trackingNumber || "";
const items = ((order as any).lines || []).map((line: any) => ({
id: line.id,
name: line.variant?.product?.name || "Unknown Product",
quantity: line.quantity,
price: formatPrice(line.totalPrice?.gross?.amount || 0, line.totalPrice?.gross?.currency || "USD"),
}));
const orderData = {
orderId: order.id,
orderNumber: order.number || "Unknown",
customerName: (order as any).shippingAddress?.firstName
? `${(order as any).shippingAddress.firstName} ${(order as any).shippingAddress.lastName || ""}`.trim()
: order.userEmail?.split("@")[0] || "Customer",
items,
};
console.log(`Order fulfilled: ${order.number} for ${order.userEmail} (lang: ${order.languageCode}, tracking: ${trackingNumber})`);
try {
// Get email templates
const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderShippedEmails(order, fulfillment);
// Send customer email
if (order.userEmail) {
await sendOrderShippedEmail({
const customerResult = await resend.emails.send({
from: "Manoon Oils <support@mail.manoonoils.com>",
to: order.userEmail,
orderData,
subject: customerSubject,
html: customerHtml,
});
console.log(`Customer notification sent for fulfilled order ${order.number}`);
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 <support@mail.manoonoils.com>",
to: adminEmail,
subject: adminSubject,
html: adminHtml,
});
console.log(`Admin shipped email sent to ${adminEmail}: ${adminResult.data?.id || 'failed'}`);
}
} catch (error) {
console.error("Failed to send email:", error);
console.error("Failed to send shipped emails:", error);
}
return res.status(200).end();
@@ -60,4 +70,4 @@ export const config = {
api: {
bodyParser: false,
},
};
};