Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 513a8b7866 | |||
| 41fd8a5099 | |||
| 7b52ab585a | |||
| debb1365b5 |
+1
-36
@@ -1,40 +1,5 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Build output
|
||||
.next
|
||||
.nuxt
|
||||
dist
|
||||
build
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Misc
|
||||
README.md
|
||||
*.md
|
||||
.DS_Store
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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"
|
||||
@@ -1,112 +1,140 @@
|
||||
<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>
|
||||
# Saleor Core Extensions
|
||||
|
||||
<div align="center">
|
||||
<h1>Saleor App Template</h1>
|
||||
</div>
|
||||
A Saleor app that sends email notifications for order events (created, fulfilled, cancelled) using React Email templates.
|
||||
|
||||
<div align="center">
|
||||
<p>Bare-bones boilerplate for writing Saleor Apps with Next.js.</p>
|
||||
</div>
|
||||
## Features
|
||||
|
||||
<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>
|
||||
- **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
|
||||
|
||||
> [!TIP]
|
||||
> Questions or issues? Check our [discord](https://discord.gg/H52JTZAtSH) channel for help.
|
||||
## Installation
|
||||
|
||||
### What is Saleor App
|
||||
### Option 1: Install via 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:
|
||||
In your Saleor Dashboard, go to Apps → Install App → Enter manifest URL:
|
||||
|
||||
- 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).
|
||||
```
|
||||
https://your-app-domain.com/api/manifest
|
||||
```
|
||||
|
||||
### What's included?
|
||||
### Option 2: Manual Installation
|
||||
|
||||
- 🚀 Communication between Saleor instance and Saleor App
|
||||
- 📖 Manifest with webhooks using custom query
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/your-org/saleor-core-extensions.git
|
||||
cd saleor-core-extensions
|
||||
|
||||
### Why Next.js
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
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.
|
||||
# Build Docker image
|
||||
docker build -t ghcr.io/your-org/saleor-core-extensions:latest .
|
||||
|
||||
### Learn more about Apps
|
||||
# Deploy to your K8s cluster
|
||||
kubectl apply -f deployment.yaml
|
||||
```
|
||||
|
||||
[Apps guide](https://docs.saleor.io/docs/3.x/developer/extending/apps/key-concepts)
|
||||
## 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.
|
||||
|
||||
## Development
|
||||
|
||||
#### 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:
|
||||
|
||||
```
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. Start the local server with:
|
||||
|
||||
```
|
||||
# Start development server
|
||||
pnpm dev
|
||||
|
||||
# Generate GraphQL types
|
||||
pnpm generate
|
||||
|
||||
# Build for production
|
||||
pnpm build
|
||||
```
|
||||
|
||||
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:
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
[YOUR_SALEOR_DASHBOARD_URL]/apps/install?manifestUrl=[YOUR_APP_TUNNEL_MANIFEST_URL]
|
||||
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
|
||||
```
|
||||
|
||||
This template host manifest at `/api/manifest`
|
||||
## License
|
||||
|
||||
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).
|
||||
MIT
|
||||
|
||||
-312
@@ -1,312 +0,0 @@
|
||||
# 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*
|
||||
Generated
+20
-185
File diff suppressed because one or more lines are too long
@@ -2,89 +2,12 @@ fragment OrderCancelledWebhookPayload on OrderCancelled {
|
||||
order {
|
||||
id
|
||||
number
|
||||
created
|
||||
status
|
||||
userEmail
|
||||
languageCode
|
||||
channel {
|
||||
slug
|
||||
}
|
||||
user {
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
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
|
||||
}
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,18 +82,5 @@ fragment OrderCreatedWebhookPayload on OrderCreated {
|
||||
currency
|
||||
}
|
||||
}
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
transactions {
|
||||
id
|
||||
message
|
||||
externalUrl
|
||||
events {
|
||||
message
|
||||
type
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,95 +2,17 @@ fragment OrderFulfilledWebhookPayload on OrderFulfilled {
|
||||
order {
|
||||
id
|
||||
number
|
||||
created
|
||||
status
|
||||
userEmail
|
||||
languageCode
|
||||
channel {
|
||||
slug
|
||||
}
|
||||
user {
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
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
|
||||
}
|
||||
status
|
||||
fulfillments {
|
||||
id
|
||||
status
|
||||
created
|
||||
trackingNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@
|
||||
"@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",
|
||||
|
||||
Generated
+8
-42
@@ -20,9 +20,6 @@ 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)
|
||||
@@ -2481,8 +2478,8 @@ packages:
|
||||
brace-expansion@1.1.11:
|
||||
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
|
||||
|
||||
brace-expansion@2.0.3:
|
||||
resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==}
|
||||
brace-expansion@2.0.2:
|
||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
@@ -3305,11 +3302,6 @@ 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==}
|
||||
|
||||
@@ -3873,9 +3865,6 @@ 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}
|
||||
@@ -4746,11 +4735,6 @@ 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==}
|
||||
|
||||
@@ -4967,9 +4951,6 @@ 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'}
|
||||
@@ -5087,7 +5068,7 @@ snapshots:
|
||||
'@babel/core': 7.23.0
|
||||
'@babel/generator': 7.23.0
|
||||
'@babel/parser': 7.23.0
|
||||
'@babel/runtime': 7.23.1
|
||||
'@babel/runtime': 7.25.6
|
||||
'@babel/traverse': 7.23.0
|
||||
'@babel/types': 7.23.0
|
||||
babel-preset-fbjs: 3.4.0(@babel/core@7.23.0)
|
||||
@@ -7725,7 +7706,7 @@ snapshots:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@2.0.3:
|
||||
brace-expansion@2.0.2:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
@@ -8788,15 +8769,6 @@ 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: {}
|
||||
@@ -9358,7 +9330,7 @@ snapshots:
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.3
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -9385,8 +9357,6 @@ 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
|
||||
@@ -9799,7 +9769,7 @@ snapshots:
|
||||
|
||||
relay-runtime@12.0.0:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.1
|
||||
'@babel/runtime': 7.25.6
|
||||
fbjs: 3.0.5
|
||||
invariant: 2.2.4
|
||||
transitivePeerDependencies:
|
||||
@@ -10025,7 +9995,8 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.0: {}
|
||||
|
||||
source-map@0.6.1: {}
|
||||
source-map@0.6.1:
|
||||
optional: true
|
||||
|
||||
spawndamnit@2.0.0:
|
||||
dependencies:
|
||||
@@ -10278,9 +10249,6 @@ snapshots:
|
||||
|
||||
ufo@1.5.3: {}
|
||||
|
||||
uglify-js@3.19.3:
|
||||
optional: true
|
||||
|
||||
unbox-primitive@1.0.2:
|
||||
dependencies:
|
||||
call-bind: 1.0.2
|
||||
@@ -10524,8 +10492,6 @@ snapshots:
|
||||
|
||||
wonka@6.3.4: {}
|
||||
|
||||
wordwrap@1.0.0: {}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
||||
+31
-19
@@ -21,25 +21,35 @@ interface BaseLayoutProps {
|
||||
|
||||
const translations: Record<string, { footer: string; company: string }> = {
|
||||
sr: {
|
||||
footer: "ManoonOils - Prirodna kozmetika | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
footer: "",
|
||||
company: "",
|
||||
},
|
||||
en: {
|
||||
footer: "ManoonOils - Natural Cosmetics | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
footer: "",
|
||||
company: "",
|
||||
},
|
||||
de: {
|
||||
footer: "ManoonOils - Natürliche Kosmetik | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
footer: "",
|
||||
company: "",
|
||||
},
|
||||
fr: {
|
||||
footer: "ManoonOils - Cosmétiques Naturels | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
footer: "",
|
||||
company: "",
|
||||
},
|
||||
};
|
||||
|
||||
export function BaseLayout({ children, previewText, language, siteUrl }: BaseLayoutProps) {
|
||||
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);
|
||||
|
||||
return (
|
||||
<Html>
|
||||
@@ -47,18 +57,20 @@ export function BaseLayout({ children, previewText, language, siteUrl }: BaseLay
|
||||
<Preview>{previewText}</Preview>
|
||||
<Body style={styles.body}>
|
||||
<Container style={styles.container}>
|
||||
<Section style={styles.logoSection}>
|
||||
<Img
|
||||
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
||||
width="150"
|
||||
height="auto"
|
||||
alt="ManoonOils"
|
||||
style={styles.logo}
|
||||
/>
|
||||
</Section>
|
||||
{LOGO_URL && (
|
||||
<Section style={styles.logoSection}>
|
||||
<Img
|
||||
src={LOGO_URL}
|
||||
width="150"
|
||||
height="auto"
|
||||
alt={COMPANY_NAME}
|
||||
style={styles.logo}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
{children}
|
||||
<Section style={styles.footer}>
|
||||
<Text style={styles.footerText}>{t.footer}</Text>
|
||||
<Text style={styles.footerText}>{footer}</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export { getOrderCreatedEmails } from "./order-created";
|
||||
export { getOrderShippedEmails } from "./order-shipped";
|
||||
export { getOrderCancelledEmails } from "./order-cancelled";
|
||||
@@ -1,166 +0,0 @@
|
||||
/**
|
||||
* 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}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
@@ -1,218 +0,0 @@
|
||||
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)
|
||||
};
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
@@ -1,14 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
/**
|
||||
* 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:",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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();
|
||||
@@ -1,21 +1,11 @@
|
||||
// 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: [
|
||||
"https://api.manoonoils.com/graphql/",
|
||||
"http://api.manoonoils.com/graphql/",
|
||||
],
|
||||
allowedSaleorUrls,
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,8 @@ import {
|
||||
OrderCancelledWebhookPayloadFragment,
|
||||
} from "@/generated/graphql";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
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"];
|
||||
import { sendOrderCancelledEmail, formatPrice } from "@/lib/resend";
|
||||
import { settingsStore } from "@/lib/settings";
|
||||
|
||||
export const orderCancelledWebhook = new SaleorAsyncWebhook<OrderCancelledWebhookPayloadFragment>({
|
||||
name: "Order Cancelled in Saleor",
|
||||
@@ -20,7 +16,7 @@ export const orderCancelledWebhook = new SaleorAsyncWebhook<OrderCancelledWebhoo
|
||||
});
|
||||
|
||||
export default orderCancelledWebhook.createHandler(async (req, res, ctx) => {
|
||||
const { payload } = ctx;
|
||||
const { payload, event, baseUrl, authData } = ctx;
|
||||
const order = payload.order;
|
||||
|
||||
if (!order) {
|
||||
@@ -28,35 +24,65 @@ export default orderCancelledWebhook.createHandler(async (req, res, ctx) => {
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
console.log(`Order cancelled: ${order.number} for ${order.userEmail} (lang: ${order.languageCode})`);
|
||||
const webhookEnabled = await settingsStore.isWebhookEnabled("orderCancelled");
|
||||
const sendAdmin = await settingsStore.shouldSendAdminNotification("orderCancelled");
|
||||
const sendCustomer = await settingsStore.shouldSendCustomerNotification("orderCancelled");
|
||||
|
||||
try {
|
||||
// Get email templates
|
||||
const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderCancelledEmails(order);
|
||||
console.log(`❌ Order ${order.number} cancelled for customer: ${order.userEmail}`);
|
||||
console.log(`📋 Webhook settings - enabled: ${webhookEnabled}, admin: ${sendAdmin}, customer: ${sendCustomer}`);
|
||||
|
||||
// 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 (!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 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'}`);
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send cancelled emails:", error);
|
||||
} else {
|
||||
console.log("⏭️ Customer notification disabled, skipping");
|
||||
}
|
||||
|
||||
return res.status(200).end();
|
||||
@@ -66,4 +92,4 @@ export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -4,12 +4,8 @@ import {
|
||||
OrderCreatedWebhookPayloadFragment,
|
||||
} from "@/generated/graphql";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
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"];
|
||||
import { sendOrderConfirmationEmail, formatPrice } from "@/lib/resend";
|
||||
import { settingsStore } from "@/lib/settings";
|
||||
|
||||
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedWebhookPayloadFragment>({
|
||||
name: "Order Created in Saleor",
|
||||
@@ -20,7 +16,7 @@ export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedWebhookPay
|
||||
});
|
||||
|
||||
export default orderCreatedWebhook.createHandler(async (req, res, ctx) => {
|
||||
const { payload } = ctx;
|
||||
const { payload, event, baseUrl, authData } = ctx;
|
||||
const order = payload.order;
|
||||
|
||||
if (!order) {
|
||||
@@ -28,35 +24,81 @@ export default orderCreatedWebhook.createHandler(async (req, res, ctx) => {
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
console.log(`Order created: ${order.number} for ${order.userEmail} (lang: ${order.languageCode})`);
|
||||
const webhookEnabled = await settingsStore.isWebhookEnabled("orderCreated");
|
||||
const sendAdmin = await settingsStore.shouldSendAdminNotification("orderCreated");
|
||||
const sendCustomer = await settingsStore.shouldSendCustomerNotification("orderCreated");
|
||||
|
||||
try {
|
||||
// Get email templates
|
||||
const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderCreatedEmails(order);
|
||||
console.log(`🎉 Order #${order.number} created for customer: ${order.userEmail} (${order.languageCode || "EN"})`);
|
||||
console.log(`📋 Webhook settings - enabled: ${webhookEnabled}, admin: ${sendAdmin}, customer: ${sendCustomer}`);
|
||||
|
||||
// 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'}`);
|
||||
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);
|
||||
}
|
||||
} else {
|
||||
console.log("⏭️ Admin notification disabled, skipping");
|
||||
}
|
||||
|
||||
// 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'}`);
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send emails:", error);
|
||||
} else {
|
||||
console.log("⏭️ Customer notification disabled, skipping");
|
||||
}
|
||||
|
||||
return res.status(200).end();
|
||||
@@ -66,4 +108,4 @@ export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -4,12 +4,8 @@ import {
|
||||
OrderFulfilledWebhookPayloadFragment,
|
||||
} from "@/generated/graphql";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
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"];
|
||||
import { sendOrderShippedEmail, formatPrice } from "@/lib/resend";
|
||||
import { settingsStore } from "@/lib/settings";
|
||||
|
||||
export const orderFulfilledWebhook = new SaleorAsyncWebhook<OrderFulfilledWebhookPayloadFragment>({
|
||||
name: "Order Fulfilled in Saleor",
|
||||
@@ -20,7 +16,7 @@ export const orderFulfilledWebhook = new SaleorAsyncWebhook<OrderFulfilledWebhoo
|
||||
});
|
||||
|
||||
export default orderFulfilledWebhook.createHandler(async (req, res, ctx) => {
|
||||
const { payload } = ctx;
|
||||
const { payload, event, baseUrl, authData } = ctx;
|
||||
const order = payload.order;
|
||||
|
||||
if (!order) {
|
||||
@@ -28,39 +24,64 @@ export default orderFulfilledWebhook.createHandler(async (req, res, ctx) => {
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
// Get the first fulfillment with tracking info
|
||||
const fulfillment = (order as any).fulfillments?.[0];
|
||||
const trackingNumber = fulfillment?.trackingNumber || "";
|
||||
const webhookEnabled = await settingsStore.isWebhookEnabled("orderFulfilled");
|
||||
const sendAdmin = await settingsStore.shouldSendAdminNotification("orderFulfilled");
|
||||
const sendCustomer = await settingsStore.shouldSendCustomerNotification("orderFulfilled");
|
||||
|
||||
console.log(`Order fulfilled: ${order.number} for ${order.userEmail} (lang: ${order.languageCode}, tracking: ${trackingNumber})`);
|
||||
console.log(`📦 Order ${order.number} fulfilled for customer: ${order.userEmail}`);
|
||||
console.log(`📋 Webhook settings - enabled: ${webhookEnabled}, admin: ${sendAdmin}, customer: ${sendCustomer}`);
|
||||
|
||||
try {
|
||||
// Get email templates
|
||||
const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderShippedEmails(order, fulfillment);
|
||||
if (!webhookEnabled) {
|
||||
console.log("⏭️ Webhook disabled, skipping notifications");
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
// 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'}`);
|
||||
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 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'}`);
|
||||
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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send shipped emails:", error);
|
||||
} else {
|
||||
console.log("⏭️ Customer notification disabled, skipping");
|
||||
}
|
||||
|
||||
return res.status(200).end();
|
||||
@@ -70,4 +91,4 @@ export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,410 @@
|
||||
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;
|
||||
+25
-2
@@ -3,7 +3,9 @@ import { SaleorApp } from "@saleor/app-sdk/saleor-app";
|
||||
import { FileAPL } from "@saleor/app-sdk/APL/file";
|
||||
|
||||
/**
|
||||
* APL wrapper that normalizes HTTP to HTTPS for auth data lookups
|
||||
* 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)
|
||||
*/
|
||||
class NormalizingAPL implements APL {
|
||||
private apl: FileAPL;
|
||||
@@ -16,10 +18,31 @@ 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}`);
|
||||
return this.apl.get(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;
|
||||
}
|
||||
|
||||
async set(authData: AuthData): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user