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
29 changed files with 2255 additions and 973 deletions
+36 -1
View File
@@ -1,5 +1,40 @@
# Dependencies
node_modules
.pnpm-store
# Build output
.next
.nuxt
dist
build
# Git
.git
*.log
.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"
+86 -114
View File
@@ -1,140 +1,112 @@
# Saleor Core Extensions
<div align="center">
<img width="150" alt="saleor-app-template" src="https://user-images.githubusercontent.com/4006792/215185065-4ef2eda4-ca71-48cc-b14b-c776e0b491b6.png">
</div>
A Saleor app that sends email notifications for order events (created, fulfilled, cancelled) using React Email templates.
<div align="center">
<h1>Saleor App Template</h1>
</div>
## Features
<div align="center">
<p>Bare-bones boilerplate for writing Saleor Apps with Next.js.</p>
</div>
- **Order Created** - Sends confirmation emails to customer and admin
- **Order Fulfilled** - Sends shipping notification with tracking
- **Order Cancelled** - Sends cancellation notification
- **Multi-language** - Supports EN, SR, DE, FR
- **React Email** - Professional HTML emails with responsive design
<div align="center">
<a href="https://saleor.io/">Website</a>
<span> | </span>
<a href="https://docs.saleor.io/docs/3.x/">Docs</a>
<span> | </span>
<a href="https://githubbox.com/saleor/saleor-app-template">CodeSandbox</a>
</div>
## Installation
> [!TIP]
> Questions or issues? Check our [discord](https://discord.gg/H52JTZAtSH) channel for help.
### Option 1: Install via Manifest URL
### What is Saleor App
In your Saleor Dashboard, go to Apps → Install App → Enter manifest URL:
Saleor App is the fastest way of extending Saleor with custom logic using [asynchronous](https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks) and [synchronous](https://docs.saleor.io/docs/3.x/developer/extending/apps/synchronous-webhooks/key-concepts) webhooks (and vast Saleor's API). In most cases, creating an App consists of two tasks:
```
https://your-app-domain.com/api/manifest
```
- Writing webhook's code executing your custom logic.
- Developing configuration UI to be displayed in Saleor Dashboard via specialized view (designated in the App's manifest).
### Option 2: Manual Installation
### What's included?
```bash
# Clone the repository
git clone https://github.com/your-org/saleor-core-extensions.git
cd saleor-core-extensions
- 🚀 Communication between Saleor instance and Saleor App
- 📖 Manifest with webhooks using custom query
# Install dependencies
pnpm install
### Why Next.js
# Build Docker image
docker build -t ghcr.io/your-org/saleor-core-extensions:latest .
You can use any preferred technology to create Saleor Apps, but Next.js is among the most efficient for two reasons. The first is the simplicity of maintaining your API endpoints/webhooks and your apps' configuration React front-end in a single, well-organized project. The second reason is the ease and quality of local development and deployment.
# Deploy to your K8s cluster
kubectl apply -f deployment.yaml
```
### Learn more about Apps
## Configuration
### Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `SALEOR_API_URL` | Yes | - | Internal K8s URL for Saleor API (e.g., `http://saleor-api.saleor:8000/graphql/`) |
| `ALLOWED_SALEOR_URLS` | No | `http://localhost:3000` | Comma-separated list of allowed Saleor API URLs |
| `RESEND_API_KEY` | Yes | - | API key from [Resend.com](https://resend.com) |
| `FROM_EMAIL` | No | `support@mail.manoonoils.com` | Sender email address |
| `FROM_NAME` | No | `ManoonOils` | Sender name |
| `ADMIN_EMAILS` | Yes | - | Comma-separated admin emails for notifications |
| `SITE_URL` | No | `https://dev.manoonoils.com` | Public store URL |
| `DASHBOARD_URL` | No | `https://dashboard.manoonoils.com` | Saleor dashboard URL |
| `APP_IFRAME_BASE_URL` | Yes | - | Public URL where app is hosted |
| `APP_API_BASE_URL` | Yes | - | Same as APP_IFRAME_BASE_URL |
| `AUTH_DATA_FILE_PATH` | No | `/tmp/.auth-data.json` | Path for auth data storage |
| `EMAIL_LOGO_URL` | No | - | URL to company logo for emails |
| `EMAIL_COMPANY_NAME` | No | `Store` | Company name in emails |
| `EMAIL_FOOTER` | No | auto | Footer text in emails |
### Kubernetes Deployment
```yaml
env:
- name: SALEOR_API_URL
value: "http://saleor-api.saleor:8000/graphql/"
- name: APP_IFRAME_BASE_URL
value: "https://your-app.domain.com"
- name: APP_API_BASE_URL
value: "https://your-app.domain.com"
- name: RESEND_API_KEY
valueFrom:
secretKeyRef:
name: core-extensions-secrets
key: resend-api-key
- name: ADMIN_EMAILS
value: "admin@example.com"
```
## Architecture
```
┌─────────────────┐ Webhooks ┌──────────────────┐
│ Saleor Cloud │ ───────────────► │ Core Extensions │
│ │ │ App │
└─────────────────┘ └────────┬─────────┘
▲ │
│ ▼
│ ┌──────────────┐
│ │ Resend │
└────── GraphQL API ───────────│ (Email) │
└──────────────┘
```
### Internal Networking
The app uses `SALEOR_API_URL` to communicate with Saleor API internally, avoiding Cloudflare HTTP restrictions. The stored auth token is reused while the env var controls the API endpoint.
[Apps guide](https://docs.saleor.io/docs/3.x/developer/extending/apps/key-concepts)
## Development
```bash
# Install dependencies
#### Running app locally in development containers
The easiest way of running app for local development is to use [development containers](https://containers.dev/).
If you have Visual Studio Code follow their [guide](https://code.visualstudio.com/docs/devcontainers/containers#_quick-start-open-an-existing-folder-in-a-container) on how to open existing folder in container.
Development container only creates container, you still need to start the server.
Development container will have port opened:
1. `3000` - were app dev server will listen to requests
### Requirements
Before you start, make sure you have installed:
- [Node.js 22](https://nodejs.org/en/)
- [pnpm 9](https://pnpm.io/)
1. Install the dependencies by running:
```
pnpm install
```
# Start development server
2. Start the local server with:
```
pnpm dev
# Generate GraphQL types
pnpm generate
# Build for production
pnpm build
```
## Project Structure
3. Expose local environment using tunnel:
Use tunneling tools like [localtunnel](https://github.com/localtunnel/localtunnel) or [ngrok](https://ngrok.com/).
4. Install the application in your dashboard:
If you use Saleor Cloud or your local server is exposed, you can install your app by following this link:
```
src/
├── emails/
│ ├── BaseLayout.tsx # Email layout with logo/footer
│ ├── OrderConfirmation.tsx # Order confirmation email
│ ├── OrderShipped.tsx # Order shipped email
│ └── OrderCancelled.tsx # Order cancelled email
├── lib/
│ ├── resend.ts # Email sending logic
│ └── create-graphq-client.ts
├── pages/
│ ├── api/
│ │ ├── manifest.ts # App manifest
│ │ └── register.ts # App registration
│ └── webhooks/
│ ├── order-created.ts
│ ├── order-fulfilled.ts
│ └── order-cancelled.ts
└── saleor-app.ts # APL configuration
[YOUR_SALEOR_DASHBOARD_URL]/apps/install?manifestUrl=[YOUR_APP_TUNNEL_MANIFEST_URL]
```
## License
This template host manifest at `/api/manifest`
MIT
You can also install application using GQL or command line. Follow the guide [how to install your app](https://docs.saleor.io/docs/3.x/developer/extending/apps/installing-apps#installation-using-graphql-api) to learn more.
### Generated schema and typings
This project uses a `generate` npm script command to:
- Generate GraphQL schema and typed functions from Saleor's GraphQL endpoint.
- Generate types for Saleor sync webhook responses from JSON schema
Commit the `generated` folder to your repo as they are necessary for queries and keeping track of the GraphQL / JSON schema changes.
To generate GraphQL types we are using [GraphQL Codegen](https://www.graphql-code-generator.com/). For generating types from JSON schema we use [json-schema-to-typescript](https://www.npmjs.com/package/json-schema-to-typescript).
### Storing registration data - APL
During the registration process, Saleor API passes the auth token to the app. With this token App can query Saleor API with privileged access (depending on requested permissions during the installation).
To store this data, app-template use a different [APL interfaces](https://docs.saleor.io/developer/extending/apps/developing-apps/app-sdk/apl).
The choice of the APL is made using the `APL` environment variable. If the value is not set, FileAPL is used. Available choices:
- `file`: no additional setup is required. Good choice for local development. It can't be used for multi tenant-apps or be deployed (not intended for production)
- `upstash`: use [Upstash](https://upstash.com/) Redis as storage method. Free account required. It can be used for development and production and supports multi-tenancy. Requires `UPSTASH_URL` and `UPSTASH_TOKEN` environment variables to be set
If you want to use your own database, you can implement your own APL. [Check the documentation to read more](https://docs.saleor.io/developer/extending/apps/developing-apps/app-sdk/apl).
+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",
+42 -8
View File
@@ -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)
@@ -2478,8 +2481,8 @@ packages:
brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
brace-expansion@2.0.3:
resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -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'}
@@ -5068,7 +5087,7 @@ snapshots:
'@babel/core': 7.23.0
'@babel/generator': 7.23.0
'@babel/parser': 7.23.0
'@babel/runtime': 7.25.6
'@babel/runtime': 7.23.1
'@babel/traverse': 7.23.0
'@babel/types': 7.23.0
babel-preset-fbjs: 3.4.0(@babel/core@7.23.0)
@@ -7706,7 +7725,7 @@ snapshots:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.0.2:
brace-expansion@2.0.3:
dependencies:
balanced-match: 1.0.2
@@ -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: {}
@@ -9330,7 +9358,7 @@ snapshots:
minimatch@9.0.9:
dependencies:
brace-expansion: 2.0.2
brace-expansion: 2.0.3
minimist@1.2.8: {}
@@ -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
@@ -9769,7 +9799,7 @@ snapshots:
relay-runtime@12.0.0:
dependencies:
'@babel/runtime': 7.25.6
'@babel/runtime': 7.23.1
fbjs: 3.0.5
invariant: 2.2.4
transitivePeerDependencies:
@@ -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
+19 -31
View File
@@ -21,35 +21,25 @@ interface BaseLayoutProps {
const translations: Record<string, { footer: string; company: string }> = {
sr: {
footer: "",
company: "",
footer: "ManoonOils - Prirodna kozmetika | www.manoonoils.com",
company: "ManoonOils",
},
en: {
footer: "",
company: "",
footer: "ManoonOils - Natural Cosmetics | www.manoonoils.com",
company: "ManoonOils",
},
de: {
footer: "",
company: "",
footer: "ManoonOils - Natürliche Kosmetik | www.manoonoils.com",
company: "ManoonOils",
},
fr: {
footer: "",
company: "",
footer: "ManoonOils - Cosmétiques Naturels | www.manoonoils.com",
company: "ManoonOils",
},
};
const COMPANY_NAME = process.env.EMAIL_COMPANY_NAME || "Store";
const LOGO_URL = process.env.EMAIL_LOGO_URL || "";
const DEFAULT_FOOTER = process.env.EMAIL_FOOTER || `${COMPANY_NAME} | ${process.env.SITE_URL || ""}`;
function getFooter(language: string): string {
const t = translations[language] || translations.en;
const footer = t.footer || DEFAULT_FOOTER;
return footer.replace("{company}", COMPANY_NAME).replace("{siteUrl}", process.env.SITE_URL || "");
}
export function BaseLayout({ children, previewText, language, siteUrl }: BaseLayoutProps) {
const footer = getFooter(language);
const t = translations[language] || translations.en;
return (
<Html>
@@ -57,20 +47,18 @@ export function BaseLayout({ children, previewText, language, siteUrl }: BaseLay
<Preview>{previewText}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
{LOGO_URL && (
<Section style={styles.logoSection}>
<Img
src={LOGO_URL}
width="150"
height="auto"
alt={COMPANY_NAME}
style={styles.logo}
/>
</Section>
)}
<Section style={styles.logoSection}>
<Img
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
width="150"
height="auto"
alt="ManoonOils"
style={styles.logo}
/>
</Section>
{children}
<Section style={styles.footer}>
<Text style={styles.footerText}>{footer}</Text>
<Text style={styles.footerText}>{t.footer}</Text>
</Section>
</Container>
</Body>
+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;
}
-133
View File
@@ -1,133 +0,0 @@
import { promises as fs } from "fs";
import path from "path";
export interface WebhookSettings {
orderCreated: {
enabled: boolean;
sendAdminNotification: boolean;
sendCustomerNotification: boolean;
};
orderFulfilled: {
enabled: boolean;
sendAdminNotification: boolean;
sendCustomerNotification: boolean;
};
orderCancelled: {
enabled: boolean;
sendAdminNotification: boolean;
sendCustomerNotification: boolean;
};
}
export interface EmailSettings {
fromEmail: string;
fromName: string;
adminEmails: string[];
siteUrl: string;
dashboardUrl: string;
}
export interface AppSettings {
webhooks: WebhookSettings;
email: EmailSettings;
updatedAt: string;
}
const DEFAULT_SETTINGS: AppSettings = {
webhooks: {
orderCreated: {
enabled: true,
sendAdminNotification: true,
sendCustomerNotification: true,
},
orderFulfilled: {
enabled: true,
sendAdminNotification: true,
sendCustomerNotification: true,
},
orderCancelled: {
enabled: true,
sendAdminNotification: true,
sendCustomerNotification: true,
},
},
email: {
fromEmail: process.env.FROM_EMAIL || "support@mail.manoonoils.com",
fromName: process.env.FROM_NAME || "ManoonOils",
adminEmails: process.env.ADMIN_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) || [],
siteUrl: process.env.SITE_URL || "https://manoonoils.com",
dashboardUrl: process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com",
},
updatedAt: new Date().toISOString(),
};
class SettingsStore {
private filePath: string;
private settings: AppSettings | null = null;
constructor() {
this.filePath = process.env.SETTINGS_FILE_PATH || "/tmp/.app-settings.json";
}
private async ensureFileExists(): Promise<void> {
try {
await fs.access(this.filePath);
} catch {
const dir = path.dirname(this.filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(this.filePath, JSON.stringify(DEFAULT_SETTINGS, null, 2));
}
}
async get(): Promise<AppSettings> {
if (this.settings) {
return this.settings;
}
try {
await this.ensureFileExists();
const data = await fs.readFile(this.filePath, "utf-8");
this.settings = JSON.parse(data) as AppSettings;
return this.settings!;
} catch (error) {
console.error("Error reading settings:", error);
this.settings = DEFAULT_SETTINGS;
return this.settings;
}
}
async set(newSettings: Partial<AppSettings>): Promise<AppSettings> {
const current = await this.get();
const updated: AppSettings = {
...current,
...newSettings,
updatedAt: new Date().toISOString(),
};
try {
await fs.writeFile(this.filePath, JSON.stringify(updated, null, 2));
this.settings = updated;
return updated;
} catch (error) {
console.error("Error writing settings:", error);
throw error;
}
}
async isWebhookEnabled(webhook: keyof WebhookSettings): Promise<boolean> {
const settings = await this.get();
return settings.webhooks[webhook].enabled;
}
async shouldSendAdminNotification(webhook: keyof WebhookSettings): Promise<boolean> {
const settings = await this.get();
return settings.webhooks[webhook].sendAdminNotification;
}
async shouldSendCustomerNotification(webhook: keyof WebhookSettings): Promise<boolean> {
const settings = await this.get();
return settings.webhooks[webhook].sendCustomerNotification;
}
}
export const settingsStore = new SettingsStore();
+15 -5
View File
@@ -1,11 +1,21 @@
// Patch fetch to force HTTPS for api.manoonoils.com
const originalFetch = global.fetch;
global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
let url = input.toString();
if (url.startsWith('http://api.manoonoils.com/')) {
url = url.replace('http://', 'https://');
input = url;
}
return originalFetch(input, init);
};
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "@/saleor-app";
const allowedSaleorUrls = process.env.ALLOWED_SALEOR_URLS
? process.env.ALLOWED_SALEOR_URLS.split(",").map((url) => url.trim())
: ["http://localhost:3000", "https://*.saleor.cloud"];
export default createAppRegisterHandler({
apl: saleorApp.apl,
allowedSaleorUrls,
allowedSaleorUrls: [
"https://api.manoonoils.com/graphql/",
"http://api.manoonoils.com/graphql/",
],
});
-34
View File
@@ -1,34 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { settingsStore, AppSettings } from "@/lib/settings";
export async function GET() {
try {
const settings = await settingsStore.get();
return NextResponse.json(settings);
} catch (error) {
console.error("Error getting settings:", error);
return NextResponse.json({ error: "Failed to get settings" }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json() as Partial<AppSettings>;
const settings = await settingsStore.set(body);
return NextResponse.json(settings);
} catch (error) {
console.error("Error updating settings:", error);
return NextResponse.json({ error: "Failed to update settings" }, { status: 500 });
}
}
export async function PATCH(request: NextRequest) {
try {
const body = await request.json() as Partial<AppSettings>;
const settings = await settingsStore.set(body);
return NextResponse.json(settings);
} catch (error) {
console.error("Error patching settings:", error);
return NextResponse.json({ error: "Failed to patch settings" }, { status: 500 });
}
}
+32 -58
View File
@@ -4,8 +4,12 @@ import {
OrderCancelledWebhookPayloadFragment,
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
import { sendOrderCancelledEmail, formatPrice } from "@/lib/resend";
import { settingsStore } from "@/lib/settings";
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",
@@ -16,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) {
@@ -24,65 +28,35 @@ export default orderCancelledWebhook.createHandler(async (req, res, ctx) => {
return res.status(200).end();
}
const webhookEnabled = await settingsStore.isWebhookEnabled("orderCancelled");
const sendAdmin = await settingsStore.shouldSendAdminNotification("orderCancelled");
const sendCustomer = await settingsStore.shouldSendCustomerNotification("orderCancelled");
console.log(`Order cancelled: ${order.number} for ${order.userEmail} (lang: ${order.languageCode})`);
console.log(`❌ Order ${order.number} cancelled for customer: ${order.userEmail}`);
console.log(`📋 Webhook settings - enabled: ${webhookEnabled}, admin: ${sendAdmin}, customer: ${sendCustomer}`);
try {
// Get email templates
const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderCancelledEmails(order);
if (!webhookEnabled) {
console.log("⏭️ Webhook disabled, skipping notifications");
return res.status(200).end();
}
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"),
};
if (sendAdmin) {
try {
const adminEmails = process.env.ADMIN_EMAILS?.split(",").map(e => e.trim()).filter(e => e) || [];
if (adminEmails.length > 0) {
await sendOrderCancelledEmail({
to: adminEmails,
orderData,
});
console.log(`✅ Admin notification sent for cancelled order ${order.number}`);
}
} catch (error) {
console.error("❌ Failed to send admin email:", error);
// Send customer email
if (order.userEmail) {
const customerResult = await resend.emails.send({
from: "Manoon Oils <support@mail.manoonoils.com>",
to: order.userEmail,
subject: customerSubject,
html: customerHtml,
});
console.log(`Customer cancelled email sent: ${customerResult.data?.id || 'failed'}`);
}
}
if (sendCustomer) {
try {
if (order.userEmail) {
await sendOrderCancelledEmail({
to: order.userEmail,
orderData,
});
console.log(`✅ Customer notification sent for cancelled order ${order.number}`);
}
} catch (error) {
console.error("❌ Failed to send customer email:", error);
// 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'}`);
}
} else {
console.log("⏭️ Customer notification disabled, skipping");
} catch (error) {
console.error("Failed to send cancelled emails:", error);
}
return res.status(200).end();
@@ -92,4 +66,4 @@ export const config = {
api: {
bodyParser: false,
},
};
};
+32 -74
View File
@@ -4,8 +4,12 @@ import {
OrderCreatedWebhookPayloadFragment,
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
import { sendOrderConfirmationEmail, formatPrice } from "@/lib/resend";
import { settingsStore } from "@/lib/settings";
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",
@@ -16,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) {
@@ -24,81 +28,35 @@ export default orderCreatedWebhook.createHandler(async (req, res, ctx) => {
return res.status(200).end();
}
const webhookEnabled = await settingsStore.isWebhookEnabled("orderCreated");
const sendAdmin = await settingsStore.shouldSendAdminNotification("orderCreated");
const sendCustomer = await settingsStore.shouldSendCustomerNotification("orderCreated");
console.log(`Order created: ${order.number} for ${order.userEmail} (lang: ${order.languageCode})`);
console.log(`🎉 Order #${order.number} created for customer: ${order.userEmail} (${order.languageCode || "EN"})`);
console.log(`📋 Webhook settings - enabled: ${webhookEnabled}, admin: ${sendAdmin}, customer: ${sendCustomer}`);
try {
// Get email templates
const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderCreatedEmails(order);
if (!webhookEnabled) {
console.log("⏭️ Webhook disabled, skipping notifications");
return res.status(200).end();
}
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,
};
if (sendAdmin) {
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);
// Send customer email
if (order.userEmail) {
const customerResult = await resend.emails.send({
from: "Manoon Oils <support@mail.manoonoils.com>",
to: order.userEmail,
subject: customerSubject,
html: customerHtml,
});
console.log(`Customer email sent: ${customerResult.data?.id || 'failed'}`);
}
} else {
console.log("⏭️ Admin notification disabled, skipping");
}
if (sendCustomer) {
try {
if (order.userEmail) {
await sendOrderConfirmationEmail({
to: order.userEmail,
orderData,
isAdmin: false,
});
console.log(`✅ Customer confirmation sent for order #${order.number} to: ${order.userEmail}`);
} else {
console.log("⚠️ No customer email found, skipping customer notification");
}
} catch (error) {
console.error("❌ Failed to send customer email:", error);
// 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'}`);
}
} else {
console.log("⏭️ Customer notification disabled, skipping");
} catch (error) {
console.error("Failed to send emails:", error);
}
return res.status(200).end();
@@ -108,4 +66,4 @@ export const config = {
api: {
bodyParser: false,
},
};
};
+35 -56
View File
@@ -4,8 +4,12 @@ import {
OrderFulfilledWebhookPayloadFragment,
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
import { sendOrderShippedEmail, formatPrice } from "@/lib/resend";
import { settingsStore } from "@/lib/settings";
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",
@@ -16,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) {
@@ -24,64 +28,39 @@ export default orderFulfilledWebhook.createHandler(async (req, res, ctx) => {
return res.status(200).end();
}
const webhookEnabled = await settingsStore.isWebhookEnabled("orderFulfilled");
const sendAdmin = await settingsStore.shouldSendAdminNotification("orderFulfilled");
const sendCustomer = await settingsStore.shouldSendCustomerNotification("orderFulfilled");
// Get the first fulfillment with tracking info
const fulfillment = (order as any).fulfillments?.[0];
const trackingNumber = fulfillment?.trackingNumber || "";
console.log(`📦 Order ${order.number} fulfilled for customer: ${order.userEmail}`);
console.log(`📋 Webhook settings - enabled: ${webhookEnabled}, admin: ${sendAdmin}, customer: ${sendCustomer}`);
console.log(`Order fulfilled: ${order.number} for ${order.userEmail} (lang: ${order.languageCode}, tracking: ${trackingNumber})`);
if (!webhookEnabled) {
console.log("⏭️ Webhook disabled, skipping notifications");
return res.status(200).end();
}
try {
// Get email templates
const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderShippedEmails(order, fulfillment);
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,
};
if (sendAdmin) {
try {
const adminEmails = process.env.ADMIN_EMAILS?.split(",").map(e => e.trim()).filter(e => e) || [];
if (adminEmails.length > 0) {
await sendOrderShippedEmail({
to: adminEmails,
orderData,
});
console.log(`✅ Admin notification sent for fulfilled order ${order.number}`);
}
} catch (error) {
console.error("❌ Failed to send admin email:", error);
// Send customer email
if (order.userEmail) {
const customerResult = await resend.emails.send({
from: "Manoon Oils <support@mail.manoonoils.com>",
to: order.userEmail,
subject: customerSubject,
html: customerHtml,
});
console.log(`Customer shipped email sent: ${customerResult.data?.id || 'failed'}`);
}
}
if (sendCustomer) {
try {
if (order.userEmail) {
await sendOrderShippedEmail({
to: order.userEmail,
orderData,
});
console.log(`✅ Customer notification sent for fulfilled order ${order.number}`);
}
} catch (error) {
console.error("❌ Failed to send customer email:", error);
// 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'}`);
}
} else {
console.log("⏭️ Customer notification disabled, skipping");
} catch (error) {
console.error("Failed to send shipped emails:", error);
}
return res.status(200).end();
@@ -91,4 +70,4 @@ export const config = {
api: {
bodyParser: false,
},
};
};
-410
View File
@@ -1,410 +0,0 @@
import { Box, Button, Input, Text } from "@saleor/macaw-ui";
import { NextPage } from "next";
import { useCallback, useEffect, useState } from "react";
interface WebhookToggle {
enabled: boolean;
sendAdminNotification: boolean;
sendCustomerNotification: boolean;
}
interface WebhookSettings {
orderCreated: WebhookToggle;
orderFulfilled: WebhookToggle;
orderCancelled: WebhookToggle;
}
interface EmailSettings {
fromEmail: string;
fromName: string;
adminEmails: string;
siteUrl: string;
dashboardUrl: string;
}
interface Settings {
webhooks: WebhookSettings;
email: EmailSettings;
updatedAt: string;
}
const defaultSettings: Settings = {
webhooks: {
orderCreated: { enabled: true, sendAdminNotification: true, sendCustomerNotification: true },
orderFulfilled: { enabled: true, sendAdminNotification: true, sendCustomerNotification: true },
orderCancelled: { enabled: true, sendAdminNotification: true, sendCustomerNotification: true },
},
email: {
fromEmail: "",
fromName: "",
adminEmails: "",
siteUrl: "",
dashboardUrl: "",
},
updatedAt: "",
};
const webhookLabels: Record<keyof WebhookSettings, { title: string; description: string }> = {
orderCreated: {
title: "Order Created",
description: "Send email notifications when a new order is placed",
},
orderFulfilled: {
title: "Order Fulfilled",
description: "Send email notifications when an order is shipped",
},
orderCancelled: {
title: "Order Cancelled",
description: "Send email notifications when an order is cancelled",
},
};
const Toggle: React.FC<{
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}> = ({ checked, onChange, disabled }) => (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
style={{
position: "relative",
width: "44px",
height: "24px",
borderRadius: "12px",
backgroundColor: checked ? "#22c55e" : "#d1d5db",
border: "none",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
transition: "background-color 0.2s",
padding: 0,
}}
>
<span
style={{
position: "absolute",
top: "2px",
left: checked ? "22px" : "2px",
width: "20px",
height: "20px",
borderRadius: "50%",
backgroundColor: "white",
transition: "left 0.2s",
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
}}
/>
</button>
);
const Card: React.FC<{
children: React.ReactNode;
style?: React.CSSProperties;
}> = ({ children, style }) => (
<div
style={{
backgroundColor: "#ffffff",
borderRadius: "12px",
border: "1px solid #e5e7eb",
padding: "24px",
...style,
}}
>
{children}
</div>
);
const SettingsPage: NextPage = () => {
const [settings, setSettings] = useState<Settings>(defaultSettings);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const bgColor = "#f9fafb";
const textColor = "#111827";
const subtextColor = "#6b7280";
const borderColor = "#e5e7eb";
useEffect(() => {
fetch("/api/settings")
.then((res) => res.json())
.then((data: Settings) => {
setSettings({
...data,
email: {
...data.email,
adminEmails: Array.isArray(data.email.adminEmails)
? data.email.adminEmails.join(", ")
: data.email.adminEmails || "",
},
});
setLoading(false);
})
.catch((err) => {
console.error("Failed to load settings:", err);
setError("Failed to load settings");
setLoading(false);
});
}, []);
const handleWebhookChange = useCallback(
(webhook: keyof WebhookSettings, field: keyof WebhookToggle) => {
setSettings((prev) => ({
...prev,
webhooks: {
...prev.webhooks,
[webhook]: {
...prev.webhooks[webhook],
[field]: !prev.webhooks[webhook][field],
},
},
}));
setSaved(false);
},
[]
);
const handleEmailChange = useCallback((field: keyof EmailSettings, value: string) => {
setSettings((prev) => ({
...prev,
email: {
...prev.email,
[field]: value,
},
}));
setSaved(false);
}, []);
const handleSave = useCallback(async () => {
setSaving(true);
setError(null);
setSaved(false);
try {
const toSave = {
...settings,
email: {
...settings.email,
adminEmails: settings.email.adminEmails
.split(",")
.map((e) => e.trim())
.filter(Boolean),
},
};
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toSave),
});
if (!res.ok) {
throw new Error("Failed to save settings");
}
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
console.error("Failed to save settings:", err);
setError("Failed to save settings. Please try again.");
} finally {
setSaving(false);
}
}, [settings]);
if (loading) {
return (
<Box padding={8} style={{ backgroundColor: bgColor, minHeight: "100vh" }}>
<Text size={5} style={{ color: textColor, fontWeight: "bold" }}>
Loading...
</Text>
</Box>
);
}
return (
<Box padding={8} style={{ backgroundColor: bgColor, minHeight: "100vh" }}>
<Box marginBottom={8}>
<Text size={7} style={{ color: textColor, fontWeight: "bold" }}>
Email Notifications
</Text>
<Text style={{ color: subtextColor, marginTop: "8px" }}>
Configure when to send email notifications for order events
</Text>
</Box>
{error && (
<Box
padding={4}
marginBottom={6}
style={{
backgroundColor: "#fee2e2",
borderRadius: "8px",
border: "1px solid #ef4444",
}}
>
<Text style={{ color: "#991b1b" }}>{error}</Text>
</Box>
)}
<Box marginBottom={6}>
<Text size={5} style={{ color: textColor, fontWeight: "bold", marginBottom: "16px" }}>
Webhooks
</Text>
{(Object.keys(webhookLabels) as Array<keyof WebhookSettings>).map((webhook) => {
const { title, description } = webhookLabels[webhook];
const webhookSettings = settings.webhooks[webhook];
return (
<Card key={webhook} style={{ marginBottom: "16px" }}>
<Box display="flex" alignItems="flex-start" justifyContent="space-between">
<Box style={{ flex: 1 }}>
<Text style={{ color: textColor, fontWeight: "bold" }}>
{title}
</Text>
<Text size={3} style={{ color: subtextColor, marginTop: "4px" }}>
{description}
</Text>
</Box>
<Toggle
checked={webhookSettings.enabled}
onChange={() => handleWebhookChange(webhook, "enabled")}
/>
</Box>
{webhookSettings.enabled && (
<Box
marginTop={4}
paddingTop={4}
style={{ borderTop: `1px solid ${borderColor}` }}
>
<Box
display="flex"
alignItems="center"
justifyContent="space-between"
marginBottom={3}
>
<Text size={3} style={{ color: subtextColor }}>
Send to admin emails
</Text>
<Toggle
checked={webhookSettings.sendAdminNotification}
onChange={() => handleWebhookChange(webhook, "sendAdminNotification")}
/>
</Box>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Text size={3} style={{ color: subtextColor }}>
Send to customer
</Text>
<Toggle
checked={webhookSettings.sendCustomerNotification}
onChange={() => handleWebhookChange(webhook, "sendCustomerNotification")}
/>
</Box>
</Box>
)}
</Card>
);
})}
</Box>
<Box marginBottom={6}>
<Text size={5} style={{ color: textColor, fontWeight: "bold", marginBottom: "16px" }}>
Email Configuration
</Text>
<Card>
<Box marginBottom={4}>
<Text size={3} style={{ color: textColor, fontWeight: 500, marginBottom: "8px" }}>
From Name
</Text>
<Input
value={settings.email.fromName}
onChange={(e) => handleEmailChange("fromName", e.target.value)}
placeholder="ManoonOils"
style={{ width: "100%" }}
/>
</Box>
<Box marginBottom={4}>
<Text size={3} style={{ color: textColor, fontWeight: 500, marginBottom: "8px" }}>
From Email
</Text>
<Input
type="email"
value={settings.email.fromEmail}
onChange={(e) => handleEmailChange("fromEmail", e.target.value)}
placeholder="support@mail.manoonoils.com"
style={{ width: "100%" }}
/>
</Box>
<Box marginBottom={4}>
<Text size={3} style={{ color: textColor, fontWeight: 500, marginBottom: "8px" }}>
Admin Emails (comma separated)
</Text>
<Input
value={settings.email.adminEmails}
onChange={(e) => handleEmailChange("adminEmails", e.target.value)}
placeholder="admin@example.com, manager@example.com"
style={{ width: "100%" }}
/>
</Box>
<Box marginBottom={4}>
<Text size={3} style={{ color: textColor, fontWeight: 500, marginBottom: "8px" }}>
Store URL
</Text>
<Input
type="url"
value={settings.email.siteUrl}
onChange={(e) => handleEmailChange("siteUrl", e.target.value)}
placeholder="https://manoonoils.com"
style={{ width: "100%" }}
/>
</Box>
<Box>
<Text size={3} style={{ color: textColor, fontWeight: 500, marginBottom: "8px" }}>
Dashboard URL
</Text>
<Input
type="url"
value={settings.email.dashboardUrl}
onChange={(e) => handleEmailChange("dashboardUrl", e.target.value)}
placeholder="https://dashboard.manoonoils.com"
style={{ width: "100%" }}
/>
</Box>
</Card>
</Box>
<Box display="flex" alignItems="center" gap={4}>
<Button
variant="primary"
onClick={handleSave}
disabled={saving}
>
{saving ? "Saving..." : "Save Settings"}
</Button>
{saved && (
<Text size={3} style={{ color: "#22c55e" }}>
Settings saved successfully!
</Text>
)}
</Box>
{settings.updatedAt && (
<Text size={3} style={{ color: subtextColor, marginTop: "16px" }}>
Last updated: {new Date(settings.updatedAt).toLocaleString()}
</Text>
)}
</Box>
);
};
export default SettingsPage;
+2 -25
View File
@@ -3,9 +3,7 @@ import { SaleorApp } from "@saleor/app-sdk/saleor-app";
import { FileAPL } from "@saleor/app-sdk/APL/file";
/**
* APL wrapper that:
* 1. Normalizes HTTP to HTTPS for auth data lookups (for Cloudflare compatibility)
* 2. Prefers SALEOR_API_URL env var over stored auth URL (for internal networking)
* APL wrapper that normalizes HTTP to HTTPS for auth data lookups
*/
class NormalizingAPL implements APL {
private apl: FileAPL;
@@ -18,31 +16,10 @@ class NormalizingAPL implements APL {
return url.replace(/^http:\/\//, "https://");
}
/**
* Get auth data and optionally override saleorApiUrl with env var
*/
async get(saleorApiUrl: string): Promise<AuthData | undefined> {
const normalizedUrl = this.normalizeUrl(saleorApiUrl);
console.log(`[NormalizingAPL] Looking up auth for: ${saleorApiUrl} -> ${normalizedUrl}`);
const authData = await this.apl.get(normalizedUrl);
if (!authData) {
return undefined;
}
// If SALEOR_API_URL is set, prefer it over the stored URL
// This enables internal networking (e.g., http://saleor-api.saleor:8000)
// while still using the stored token for authentication
if (process.env.SALEOR_API_URL) {
console.log(`[NormalizingAPL] Using SALEOR_API_URL: ${process.env.SALEOR_API_URL} (env var overrides stored: ${authData.saleorApiUrl})`);
return {
...authData,
saleorApiUrl: process.env.SALEOR_API_URL,
};
}
return authData;
return this.apl.get(normalizedUrl);
}
async set(authData: AuthData): Promise<void> {