Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebe97f6714 | |||
| 6492619cbc | |||
| 28b864ecdb | |||
| 06ff847e7e | |||
| 1089f03ee3 | |||
| 2065b24d7a | |||
| 33fb9a8452 |
@@ -0,0 +1,40 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Build output
|
||||
.next
|
||||
.nuxt
|
||||
dist
|
||||
build
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Misc
|
||||
README.md
|
||||
*.md
|
||||
+8
-14
@@ -1,14 +1,8 @@
|
||||
# Saleor App Configuration
|
||||
APP_IFRAME_BASE_URL=https://core-extensions.manoonoils.com
|
||||
APP_API_BASE_URL=https://core-extensions.manoonoils.com
|
||||
|
||||
# Email Configuration
|
||||
RESEND_API_KEY=your_resend_api_key
|
||||
FROM_EMAIL=support@mail.manoonoils.com
|
||||
FROM_NAME=ManoonOils
|
||||
ADMIN_EMAILS=me@hytham.me,tamara@hytham.me
|
||||
SITE_URL=https://dev.manoonoils.com
|
||||
|
||||
# Auth Persistence Layer
|
||||
# Use 'file' for development, 'upstash' for production
|
||||
APL=file
|
||||
# Local development variables. When developped locally with Saleor inside docker, these can be set to:
|
||||
#
|
||||
# APP_IFRAME_BASE_URL = http://localhost:3000, so Dashboard on host can access iframe
|
||||
# APP_API_BASE_URL=http://host.docker.internal:3000 - so Saleor can reach App running on host, from the container.
|
||||
#
|
||||
# If developped with tunnels, set this empty, it will fallback to address the app is reached from (default port 3000).
|
||||
APP_IFRAME_BASE_URL=
|
||||
APP_API_BASE_URL=
|
||||
@@ -0,0 +1,74 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger BuildKit Build
|
||||
run: |
|
||||
echo "Building commit: $GITHUB_SHA"
|
||||
|
||||
# Delete old job
|
||||
kubectl delete job build-core-extensions-action -n gitea --ignore-not-found=true 2>/dev/null || true
|
||||
|
||||
# Create build job
|
||||
cat << EOF | kubectl apply -f -
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: build-core-extensions-action
|
||||
namespace: gitea
|
||||
spec:
|
||||
ttlSecondsAfterFinished: 86400
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
initContainers:
|
||||
- name: clone
|
||||
image: alpine/git:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
git clone --depth 1 http://gitea:3000/unchained/saleor-core-extensions.git /workspace
|
||||
cd /workspace && git checkout $GITHUB_SHA
|
||||
echo "Building: \$(git rev-parse --short HEAD)"
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
containers:
|
||||
- name: build
|
||||
image: moby/buildkit:latest
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
set -e
|
||||
mkdir -p /root/.docker
|
||||
cp /docker-config/.dockerconfigjson /root/.docker/config.json
|
||||
echo "Building with BuildKit..."
|
||||
buildctl --addr tcp://buildkit.gitea.svc.cluster.local:1234 build \ --frontend dockerfile.v0 \ --local context=/workspace \ --local dockerfile=/workspace \ --output type=image,name=ghcr.io/unchainedio/saleor-core-extensions:latest,push=true
|
||||
echo "Build complete! Triggering Flux..."
|
||||
kubectl annotate kustomization -n flux-system saleor --overwrite reconcile.fluxcd.io/requestedAt=\"$(date +%s)\"
|
||||
echo "Done!"
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
- name: docker-config
|
||||
mountPath: /docker-config
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: workspace
|
||||
emptyDir: {}
|
||||
- name: docker-config
|
||||
secret:
|
||||
secretName: ghcr-pull-secret
|
||||
EOF
|
||||
|
||||
echo "Build job created!"
|
||||
kubectl wait --for=condition=complete job/build-core-extensions-action -n gitea --timeout=600s || echo "Build running in background"
|
||||
@@ -1,18 +0,0 @@
|
||||
# Configuration Checklist
|
||||
|
||||
## Environment Variables (Required)
|
||||
- [x] APP_API_BASE_URL = https://core-extensions.manoonoils.com
|
||||
- [x] APP_IFRAME_BASE_URL = https://core-extensions.manoonoils.com
|
||||
- [x] AUTH_DATA_FILE_PATH = /data/.auth-data.json
|
||||
- [x] SETTINGS_FILE_PATH = /data/.app-settings.json
|
||||
- [x] SALEOR_API_URL = http://saleor-api.saleor:8000/graphql/
|
||||
|
||||
## File System
|
||||
- [x] /data is a persistent volume (PVC mounted)
|
||||
- [x] nextjs user (uid 1001) has write access to /data
|
||||
- [x] FileAPL configured to use AUTH_DATA_FILE_PATH
|
||||
|
||||
## Networking
|
||||
- [x] Ingress: core-extensions.manoonoils.com
|
||||
- [x] Service exposes port 3000
|
||||
- [x] Container runs as nextjs user (not root)
|
||||
+9
-16
@@ -2,26 +2,22 @@ FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Use npm with legacy peer deps to avoid lockfile issues
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
RUN npm run generate && npm run build
|
||||
|
||||
# Build the app
|
||||
RUN npm run generate:app-graphql-types
|
||||
RUN npm run generate:app-webhooks-types
|
||||
RUN npx next build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
@@ -31,7 +27,4 @@ USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,97 +0,0 @@
|
||||
# Saleor Core Extensions - Rapid Development Setup
|
||||
|
||||
## Quick Start for Local Development
|
||||
|
||||
Instead of the slow Docker → K8s → Saleor cycle, use this rapid development workflow:
|
||||
|
||||
### 1. Start Local Development Server with Tunnel
|
||||
|
||||
```bash
|
||||
cd /home/unchained/saleor-core-extensions
|
||||
npm run dev:tunnel
|
||||
```
|
||||
|
||||
This will:
|
||||
- Start the Next.js dev server on http://localhost:3000
|
||||
- Create a public tunnel URL (e.g., https://abc123.loca.lt)
|
||||
- Display the manifest URL to use in Saleor
|
||||
|
||||
### 2. Install App in Saleor Dashboard
|
||||
|
||||
1. Go to https://dashboard.manoonoils.com
|
||||
2. Navigate to **Apps → Install external app**
|
||||
3. Paste the tunnel manifest URL shown in the terminal (e.g., `https://abc123.loca.lt/api/manifest`)
|
||||
4. Click **Install**
|
||||
|
||||
### 3. Test Changes Instantly
|
||||
|
||||
- Make code changes in `/src/pages/api/`
|
||||
- Changes auto-reload immediately
|
||||
- Saleor sees updates via the tunnel instantly
|
||||
- **No Docker rebuild needed!**
|
||||
|
||||
### 4. When Ready for Production
|
||||
|
||||
Once everything works locally:
|
||||
|
||||
```bash
|
||||
# Build and push production image
|
||||
docker build -t ghcr.io/unchainedio/saleor-core-extensions:n8n-webhooks .
|
||||
docker push ghcr.io/unchainedio/saleor-core-extensions:n8n-webhooks
|
||||
|
||||
# Deploy to K8s (Flux will pick it up automatically)
|
||||
```
|
||||
|
||||
## How Tunnels Work
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ Your Local App │ ←──── │ Tunnel Service │ ←──── │ Saleor Cloud │
|
||||
│ localhost:3000 │ │ (localtunnel) │ │ Webhooks │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
│ Auto-reload on save │ Public HTTPS URL │ Triggers webhooks
|
||||
└────────────────────────────┴────────────────────────────┘
|
||||
```
|
||||
|
||||
## Environment Variables for Local Dev
|
||||
|
||||
Create `.env.local`:
|
||||
|
||||
```env
|
||||
# Use tunnel URL for Saleor API
|
||||
APP_IFRAME_BASE_URL=https://YOUR_TUNNEL_URL.loca.lt
|
||||
APP_API_BASE_URL=https://YOUR_TUNNEL_URL.loca.lt
|
||||
|
||||
# Point to your Saleor instance
|
||||
SALEOR_API_URL=https://api.manoonoils.com/graphql/
|
||||
|
||||
# Email settings (optional for testing)
|
||||
RESEND_API_KEY=test
|
||||
FROM_EMAIL=support@mail.manoonoils.com
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tunnel disconnects
|
||||
- Just restart `npm run dev:tunnel`
|
||||
- Update the manifest URL in Saleor with the new tunnel URL
|
||||
|
||||
### "Invalid manifest" error
|
||||
- Check the tunnel URL is accessible: `curl https://YOUR_TUNNEL.loca.lt/api/manifest`
|
||||
- Ensure `allowedSaleorApiUrls` includes your Saleor URL
|
||||
|
||||
### Changes not reflecting
|
||||
- The dev server has hot reload
|
||||
- If stuck, press `Ctrl+C` and restart `npm run dev:tunnel`
|
||||
|
||||
## Why This Is Better
|
||||
|
||||
| Method | Feedback Loop | Setup Time |
|
||||
|--------|--------------|------------|
|
||||
| Docker → K8s → Saleor | 5-10 minutes per change | Complex |
|
||||
| Local + Tunnel | **Instant** (seconds) | Simple |
|
||||
|
||||
## Current Status
|
||||
|
||||
The Docker image is already deployed and working. This tunnel setup is for **rapid development and testing** before committing changes.
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
# Core-Extensions Email Service - Roadmap
|
||||
|
||||
## Current Status ✅
|
||||
|
||||
- **Order Created**: Working perfectly (customer name, phone, all languages)
|
||||
- **Order Fulfilled**: Working but shows email instead of customer name
|
||||
- **Order Cancelled**: Working but shows email instead of customer name
|
||||
- **All webhooks firing**: ✅ Emails sent via Resend
|
||||
- **Multi-language support**: ✅ Working (tested all 4 languages - SR, EN, DE, FR)
|
||||
- **Admin notifications**: ✅ Both admins receiving emails
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1: Fix Broken Things (Priority)
|
||||
|
||||
### 1.1 Fix Customer Name in Fulfilled/Cancelled Emails
|
||||
|
||||
**Problem**: Fulfilled and Cancelled webhooks receive partial data from Saleor (no `shippingAddress.firstName`, no `languageCode`)
|
||||
|
||||
**Solution**: Query Saleor API when these webhooks fire to get full order details
|
||||
|
||||
**Files to modify**:
|
||||
- `src/pages/api/webhooks/order-fulfilled.ts`
|
||||
- `src/pages/api/webhooks/order-cancelled.ts`
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Add GraphQL query to fetch full order data including `shippingAddress` and `languageCode`
|
||||
- [ ] Update email template functions to use complete order data
|
||||
- [ ] Test with real fulfillments and cancellations
|
||||
|
||||
**Effort**: Medium
|
||||
**Impact**: High (fixes broken UX)
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Fix Environment Variables Usage
|
||||
|
||||
**Problem**: Hardcoded values in webhook files override env vars
|
||||
|
||||
**Current**:
|
||||
```typescript
|
||||
const ADMIN_EMAILS = ["me@hytham.me", "tamara@hytham.me"];
|
||||
from: "Manoon Oils <support@mail.manoonoils.com>"
|
||||
```
|
||||
|
||||
**Should be**:
|
||||
```typescript
|
||||
const ADMIN_EMAILS = process.env.ADMIN_EMAILS?.split(",") || [];
|
||||
from: `${process.env.FROM_NAME} <${process.env.FROM_EMAIL}>`
|
||||
```
|
||||
|
||||
**Files to modify**:
|
||||
- `src/pages/api/webhooks/order-created.ts`
|
||||
- `src/pages/api/webhooks/order-fulfilled.ts`
|
||||
- `src/pages/api/webhooks/order-cancelled.ts`
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Replace hardcoded `ADMIN_EMAILS` with env var
|
||||
- [ ] Replace hardcoded `from` addresses with env vars
|
||||
- [ ] Update `.env.example` with all required variables
|
||||
- [ ] Document env vars in README
|
||||
|
||||
**Effort**: Low
|
||||
**Impact**: Medium (first step toward reusability)
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Fix Resend Daily Quota Issue
|
||||
|
||||
**Problem**: Multiple emails per order (3 customer + 6 admin = 9 emails per order)
|
||||
|
||||
**Solutions to consider**:
|
||||
1. Batch admin emails (send one email with multiple recipients)
|
||||
2. Add option to disable admin emails for testing
|
||||
3. Use BCC for admin emails
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Change admin email sending to use single email with multiple recipients or BCC
|
||||
- [ ] Add `DISABLE_ADMIN_EMAILS` env var for testing
|
||||
- [ ] Consider batching order confirmations
|
||||
|
||||
**Effort**: Low
|
||||
**Impact**: High (saves quota)
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2: Make It Reusable (Foundation)
|
||||
|
||||
### 2.1 Extract Brand Configuration to Env Vars
|
||||
|
||||
**Problem**: 20+ hardcoded Manoon Oils references
|
||||
|
||||
**Files to modify**:
|
||||
- `src/lib/email-templates/order-created.ts`
|
||||
- `src/lib/email-templates/order-shipped.ts`
|
||||
- `src/lib/email-templates/order-cancelled.ts`
|
||||
|
||||
**New env vars needed**:
|
||||
```
|
||||
COMPANY_NAME=Manoon Oils
|
||||
LOGO_URL=https://minio-api.nodecrew.me/...
|
||||
SITE_URL=https://manoonoils.com
|
||||
DASHBOARD_URL=https://dashboard.manoonoils.com
|
||||
TRACKING_URL_TEMPLATE=https://track.manoonoils.com/{trackingNumber}
|
||||
SUPPORT_EMAIL=support@manoonoils.com
|
||||
```
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Replace all hardcoded company names
|
||||
- [ ] Replace all hardcoded URLs
|
||||
- [ ] Replace logo URL
|
||||
- [ ] Update all email templates
|
||||
|
||||
**Effort**: Medium
|
||||
**Impact**: High (enables reusability)
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Unify Email Template System
|
||||
|
||||
**Problem**: Two competing systems (React Email vs Handlebars)
|
||||
|
||||
**Decision needed**:
|
||||
- **Option A**: Stick with Handlebars (simpler, already working)
|
||||
- **Option B**: Migrate to React Email (type-safe, better for complex layouts)
|
||||
- **Option C**: Support both (more complex)
|
||||
|
||||
**Recommendation**: Stick with Handlebars for now (it's working), document the React Email files as "future improvement"
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Document the two systems in README
|
||||
- [ ] Remove unused React Email code OR move to separate folder
|
||||
- [ ] Keep Handlebars as primary system
|
||||
|
||||
**Effort**: Low
|
||||
**Impact**: Low (cleanup)
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3: Advanced Features
|
||||
|
||||
### 3.1 Configuration via Saleor Metadata
|
||||
|
||||
**Problem**: Currently requires code changes to customize
|
||||
|
||||
**Solution**: Store configuration in Saleor's app metadata
|
||||
|
||||
**Benefits**:
|
||||
- No code changes needed for new stores
|
||||
- Configurable via Saleor dashboard
|
||||
- Single deployment serves multiple stores
|
||||
|
||||
**Files to modify**:
|
||||
- Add configuration service
|
||||
- Modify all webhook handlers to fetch config
|
||||
- Add settings UI in app
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Create configuration interface
|
||||
- [ ] Add GraphQL queries to fetch config from Saleor metadata
|
||||
- [ ] Update webhook handlers to use dynamic config
|
||||
- [ ] Create settings page in app
|
||||
- [ ] Cache configuration to avoid API calls on every webhook
|
||||
|
||||
**Effort**: High
|
||||
**Impact**: High (true multi-tenancy)
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Add Email Preview/Test Feature
|
||||
|
||||
**Problem**: Hard to test email templates without creating real orders
|
||||
|
||||
**Solution**: Add API endpoint to preview/test emails
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Create `/api/test-email` endpoint
|
||||
- [ ] Allow sending test emails to any address
|
||||
- [ ] Include sample order data for preview
|
||||
- [ ] Add to app UI for easy testing
|
||||
|
||||
**Effort**: Medium
|
||||
**Impact**: Medium (developer experience)
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Phone Number Validation
|
||||
|
||||
**Problem**: Phone validation failing for some formats (GB numbers rejected)
|
||||
|
||||
**Solution**: Make phone optional OR improve validation
|
||||
|
||||
**Tasks**:
|
||||
- [ ] Investigate Saleor's phone validation
|
||||
- [ ] Either fix validation or make phone optional in templates
|
||||
- [ ] Update GraphQL fragments if needed
|
||||
|
||||
**Effort**: Low
|
||||
**Impact**: Low (edge case)
|
||||
|
||||
---
|
||||
|
||||
## PHASE 4: Documentation & Deployment
|
||||
|
||||
### 4.1 Comprehensive README
|
||||
|
||||
**Sections needed**:
|
||||
- Installation instructions
|
||||
- Environment variables reference
|
||||
- Webhook configuration guide
|
||||
- Testing instructions
|
||||
- Troubleshooting
|
||||
|
||||
### 4.2 Docker Compose for Development
|
||||
|
||||
- Local Saleor instance for testing
|
||||
- Easy development setup
|
||||
|
||||
### 4.3 CI/CD Pipeline
|
||||
|
||||
- Automated testing
|
||||
- Automated deployment
|
||||
- Version tagging
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Recommended Order of Work
|
||||
|
||||
### Next (Do First):
|
||||
1. **Fix Customer Name in Fulfilled/Cancelled** - Broken UX, high impact
|
||||
2. **Fix Env Var Usage** - Quick win, enables reusability
|
||||
3. **Fix Quota Issue** - Save money immediately
|
||||
|
||||
### Then:
|
||||
4. **Extract Brand Config** - Makes it reusable
|
||||
5. **Documentation** - Helps with future work
|
||||
|
||||
### Later:
|
||||
6. **Saleor Metadata Config** - True multi-tenancy
|
||||
7. **Email Preview Feature** - Nice to have
|
||||
8. **Advanced Features** - When needed
|
||||
|
||||
---
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Current Hardcoded Values
|
||||
|
||||
#### Brand/Company Identity:
|
||||
- Company name: "ManoonOils" (12+ instances)
|
||||
- Logo URL: MinIO URL to Manoon logo
|
||||
- Support email: support@manoonoils.com
|
||||
|
||||
#### URLs:
|
||||
- Site: https://manoonoils.com
|
||||
- Dashboard: https://dashboard.manoonoils.com/orders
|
||||
- Tracking: https://track.manoonoils.com/{trackingNumber}
|
||||
|
||||
#### Email Addresses:
|
||||
- Admin recipients: me@hytham.me, tamara@hytham.me
|
||||
- From address: Manoon Oils <support@mail.manoonoils.com>
|
||||
|
||||
### Two Email Systems
|
||||
|
||||
1. **React Email** (`/src/emails/*.tsx`)
|
||||
- Type-safe React components
|
||||
- Currently unused in webhooks
|
||||
- Good for complex layouts
|
||||
|
||||
2. **Handlebars** (`/src/lib/email-templates/*.ts`)
|
||||
- Simple string templates
|
||||
- Currently used by webhooks
|
||||
- Easy to customize with translations
|
||||
|
||||
**Recommendation**: Stick with Handlebars for production use
|
||||
|
||||
### Webhook Data Inconsistency
|
||||
|
||||
Saleor sends different data for different events:
|
||||
|
||||
- **ORDER_CREATED**: Full order data ✅
|
||||
- **ORDER_FULFILLED**: Partial data (missing shippingAddress details, languageCode) ❌
|
||||
- **ORDER_CANCELLED**: Partial data (missing shippingAddress details, languageCode) ❌
|
||||
|
||||
**Fix**: Query Saleor API in fulfilled/cancelled webhooks to fetch complete order data
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
### Current (Working):
|
||||
```bash
|
||||
RESEND_API_KEY= # Required - Resend API key
|
||||
```
|
||||
|
||||
### Needed (Not implemented):
|
||||
```bash
|
||||
ADMIN_EMAILS= # Comma-separated admin emails
|
||||
FROM_EMAIL= # Sender email address
|
||||
FROM_NAME= # Sender display name
|
||||
COMPANY_NAME= # Company/brand name
|
||||
LOGO_URL= # URL to logo image
|
||||
SITE_URL= # Customer-facing storefront URL
|
||||
DASHBOARD_URL= # Admin dashboard URL
|
||||
TRACKING_URL_TEMPLATE= # Tracking URL with {trackingNumber} placeholder
|
||||
SUPPORT_EMAIL= # Support email shown in templates
|
||||
DISABLE_ADMIN_EMAILS= # Set to "true" to disable admin notifications
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: March 28, 2026*
|
||||
Generated
+327
-13
File diff suppressed because one or more lines are too long
@@ -0,0 +1,90 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,99 @@
|
||||
fragment OrderCreatedWebhookPayload on OrderCreated {
|
||||
order {
|
||||
userEmail
|
||||
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
|
||||
}
|
||||
transactions {
|
||||
id
|
||||
message
|
||||
externalUrl
|
||||
events {
|
||||
message
|
||||
type
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
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
|
||||
}
|
||||
fulfillments {
|
||||
id
|
||||
status
|
||||
created
|
||||
trackingNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
subscription OrderCancelledSubscription {
|
||||
event {
|
||||
...OrderCancelledWebhookPayload
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
subscription OrderFulfilledSubscription {
|
||||
event {
|
||||
...OrderFulfilledWebhookPayload
|
||||
}
|
||||
}
|
||||
+4
-9
@@ -2,17 +2,12 @@ import path from "path";
|
||||
import { NextConfig } from "next";
|
||||
|
||||
const config: NextConfig = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
output: 'standalone',
|
||||
typescript: {
|
||||
// Allow build to succeed even with type errors
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
eslint: {
|
||||
// Allow build to succeed even with lint errors
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
webpack: (config) => {
|
||||
// When using `pnpm link` for local SDK development, webpack may resolve
|
||||
// react/react-dom from the linked package's node_modules (different version),
|
||||
// causing the "two Reacts" problem. Force resolution to this project's copy.
|
||||
config.resolve = {
|
||||
...config.resolve,
|
||||
alias: {
|
||||
|
||||
Generated
+121
-675
File diff suppressed because it is too large
Load Diff
+19
-16
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "saleor-core-extensions",
|
||||
"name": "saleor-app-template",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"license": "(BSD-3-Clause AND CC-BY-4.0)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "NODE_OPTIONS='--inspect' next dev",
|
||||
"build": "npm run generate && next build",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"fetch-schema": "curl https://raw.githubusercontent.com/saleor/saleor/${npm_package_config_saleor_schemaVersion}/saleor/graphql/schema.graphql > graphql/schema.graphql",
|
||||
"test": "vitest",
|
||||
"check-types": "tsc --noEmit",
|
||||
"generate": "pnpm run /generate:.*/",
|
||||
"generate": "npm run generate:app-graphql-types && npm run generate:app-webhooks-types",
|
||||
"generate:app-graphql-types": "graphql-codegen",
|
||||
"generate:app-webhooks-types": "tsx ./scripts/generate-app-webhooks-types.ts"
|
||||
},
|
||||
@@ -26,26 +27,19 @@
|
||||
"pnpm": ">=10.0.0 <11.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "^0.0.19",
|
||||
"@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",
|
||||
"urql": "^4.0.2",
|
||||
"resend": "^3.0.0"
|
||||
"resend": "^6.9.4",
|
||||
"urql": "^4.0.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "4.2.1",
|
||||
"graphql": "^16.8.1",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"jsdom": "^20.0.3",
|
||||
"vite": "5.2.10",
|
||||
"vitest": "1.5.2",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"tsx": "4.20.3",
|
||||
"json-schema-to-typescript": "^15.0.4",
|
||||
"@graphql-codegen/add": "3.2.0",
|
||||
"@graphql-codegen/cli": "3.3.1",
|
||||
"@graphql-codegen/introspection": "3.0.1",
|
||||
@@ -60,12 +54,21 @@
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/react": "^18.2.6",
|
||||
"@types/react-dom": "^18.2.4",
|
||||
"@vitejs/plugin-react": "4.2.1",
|
||||
"eslint": "8.31.0",
|
||||
"eslint-config-next": "13.1.2",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
"graphql": "^16.8.1",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"jsdom": "^20.0.3",
|
||||
"json-schema-to-typescript": "^15.0.4",
|
||||
"prettier": "^2.8.2",
|
||||
"typescript": "5.0.4"
|
||||
"tsx": "4.20.3",
|
||||
"typescript": "5.0.4",
|
||||
"vite": "5.2.10",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "1.5.2"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,tsx}": "eslint --cache --fix",
|
||||
|
||||
Generated
+10604
File diff suppressed because it is too large
Load Diff
@@ -1,39 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Start local dev server with tunnel for rapid Saleor app development
|
||||
|
||||
echo "🚀 Starting Saleor Core Extensions with tunnel..."
|
||||
echo ""
|
||||
|
||||
# Check if localtunnel is installed
|
||||
if ! command -v lt &> /dev/null; then
|
||||
echo "❌ localtunnel not found. Installing..."
|
||||
npm install -g localtunnel
|
||||
fi
|
||||
|
||||
# Start localtunnel in background and capture URL
|
||||
lt --port 3000 --print-requests &
|
||||
TUNNEL_PID=$!
|
||||
|
||||
# Wait for tunnel to establish
|
||||
sleep 3
|
||||
|
||||
# Get the tunnel URL (this is a simple approach, in reality we'd parse the output)
|
||||
echo ""
|
||||
echo "⏳ Waiting for tunnel to start..."
|
||||
sleep 2
|
||||
|
||||
echo ""
|
||||
echo "✅ Tunnel started!"
|
||||
echo ""
|
||||
echo "📝 Use this URL in Saleor Dashboard:"
|
||||
echo " https://YOUR_TUNNEL_URL.loca.lt/api/manifest"
|
||||
echo ""
|
||||
echo "🔧 Starting Next.js dev server..."
|
||||
echo ""
|
||||
|
||||
# Kill tunnel on exit
|
||||
trap "kill $TUNNEL_PID 2>/dev/null; exit" INT TERM EXIT
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface BaseLayoutProps {
|
||||
children: React.ReactNode;
|
||||
previewText: string;
|
||||
language: string;
|
||||
siteUrl: string;
|
||||
}
|
||||
|
||||
const translations: Record<string, { footer: string; company: string }> = {
|
||||
sr: {
|
||||
footer: "ManoonOils - Prirodna kozmetika | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
},
|
||||
en: {
|
||||
footer: "ManoonOils - Natural Cosmetics | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
},
|
||||
de: {
|
||||
footer: "ManoonOils - Natürliche Kosmetik | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
},
|
||||
fr: {
|
||||
footer: "ManoonOils - Cosmétiques Naturels | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
},
|
||||
};
|
||||
|
||||
export function BaseLayout({ children, previewText, language, siteUrl }: BaseLayoutProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Body style={styles.body}>
|
||||
<Container style={styles.container}>
|
||||
<Section style={styles.logoSection}>
|
||||
<Img
|
||||
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
||||
width="150"
|
||||
height="auto"
|
||||
alt="ManoonOils"
|
||||
style={styles.logo}
|
||||
/>
|
||||
</Section>
|
||||
{children}
|
||||
<Section style={styles.footer}>
|
||||
<Text style={styles.footerText}>{t.footer}</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
body: {
|
||||
backgroundColor: "#f6f6f6",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: "#ffffff",
|
||||
margin: "0 auto",
|
||||
padding: "40px 20px",
|
||||
maxWidth: "600px",
|
||||
},
|
||||
logoSection: {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "30px",
|
||||
},
|
||||
logo: {
|
||||
margin: "0 auto",
|
||||
},
|
||||
footer: {
|
||||
marginTop: "40px",
|
||||
paddingTop: "20px",
|
||||
borderTop: "1px solid #e0e0e0",
|
||||
},
|
||||
footerText: {
|
||||
color: "#666666",
|
||||
fontSize: "12px",
|
||||
textAlign: "center" as const,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,237 @@
|
||||
import { Button, Hr, Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
}
|
||||
|
||||
interface OrderCancelledProps {
|
||||
language?: string;
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerName: string;
|
||||
items: OrderItem[];
|
||||
total: string;
|
||||
reason?: string;
|
||||
siteUrl: string;
|
||||
}
|
||||
|
||||
const translations: Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
preview: string;
|
||||
greeting: string;
|
||||
orderCancelled: string;
|
||||
items: string;
|
||||
total: string;
|
||||
reason: string;
|
||||
questions: string;
|
||||
}
|
||||
> = {
|
||||
sr: {
|
||||
title: "Vaša narudžbina je otkazana",
|
||||
preview: "Vaša narudžbina je otkazana",
|
||||
greeting: "Poštovani {name},",
|
||||
orderCancelled:
|
||||
"Vaša narudžbina je otkazana. Ako niste zatražili otkazivanje, molimo kontaktirajte nas što pre.",
|
||||
items: "Artikli",
|
||||
total: "Ukupno",
|
||||
reason: "Razlog",
|
||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||
},
|
||||
en: {
|
||||
title: "Your Order Has Been Cancelled",
|
||||
preview: "Your order has been cancelled",
|
||||
greeting: "Dear {name},",
|
||||
orderCancelled:
|
||||
"Your order has been cancelled. If you did not request this cancellation, please contact us as soon as possible.",
|
||||
items: "Items",
|
||||
total: "Total",
|
||||
reason: "Reason",
|
||||
questions: "Questions? Email us at support@manoonoils.com",
|
||||
},
|
||||
de: {
|
||||
title: "Ihre Bestellung wurde storniert",
|
||||
preview: "Ihre Bestellung wurde storniert",
|
||||
greeting: "Sehr geehrte/r {name},",
|
||||
orderCancelled:
|
||||
"Ihre Bestellung wurde storniert. Wenn Sie diese Stornierung nicht angefordert haben, kontaktieren Sie uns bitte so schnell wie möglich.",
|
||||
items: "Artikel",
|
||||
total: "Gesamt",
|
||||
reason: "Grund",
|
||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||
},
|
||||
fr: {
|
||||
title: "Votre commande a été annulée",
|
||||
preview: "Votre commande a été annulée",
|
||||
greeting: "Cher(e) {name},",
|
||||
orderCancelled:
|
||||
"Votre commande a été annulée. Si vous n'avez pas demandé cette annulation, veuillez nous contacter dès que possible.",
|
||||
items: "Articles",
|
||||
total: "Total",
|
||||
reason: "Raison",
|
||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderCancelled({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerName,
|
||||
items,
|
||||
total,
|
||||
reason,
|
||||
siteUrl,
|
||||
}: OrderCancelledProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||
<Text style={styles.title}>{t.title}</Text>
|
||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||
<Text style={styles.text}>{t.orderCancelled}</Text>
|
||||
|
||||
<Section style={styles.orderInfo}>
|
||||
<Text style={styles.orderNumber}>
|
||||
<strong>Order Number:</strong> {orderNumber}
|
||||
</Text>
|
||||
{reason && (
|
||||
<Text style={styles.reason}>
|
||||
<strong>{t.reason}:</strong> {reason}
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{t.items}</Text>
|
||||
<Hr style={styles.hr} />
|
||||
{items.map((item) => (
|
||||
<Section key={item.id} style={styles.itemRow}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.quantity}x {item.name}
|
||||
</Text>
|
||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||
</Section>
|
||||
))}
|
||||
<Hr style={styles.hr} />
|
||||
<Section style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>{t.total}:</Text>
|
||||
<Text style={styles.totalValue}>{total}</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={siteUrl} style={styles.button}>
|
||||
{language === "sr" ? "Pogledajte proizvode" : "Browse Products"}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={styles.questions}>{t.questions}</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#dc2626",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
greeting: {
|
||||
fontSize: "16px",
|
||||
color: "#333333",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
text: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderInfo: {
|
||||
backgroundColor: "#fef2f2",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderNumber: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0 0 5px 0",
|
||||
},
|
||||
reason: {
|
||||
fontSize: "14px",
|
||||
color: "#991b1b",
|
||||
margin: "0",
|
||||
},
|
||||
itemsSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e0e0e0",
|
||||
margin: "10px 0",
|
||||
},
|
||||
itemRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
itemName: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
itemPrice: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
totalRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
buttonSection: {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "20px",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "12px 30px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
textDecoration: "none",
|
||||
},
|
||||
questions: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,340 @@
|
||||
import { Button, Hr, Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
}
|
||||
|
||||
interface OrderConfirmationProps {
|
||||
language?: string;
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerEmail: string;
|
||||
customerName: string;
|
||||
items: OrderItem[];
|
||||
total: string;
|
||||
shippingAddress?: string;
|
||||
billingAddress?: string;
|
||||
phone?: string;
|
||||
siteUrl: string;
|
||||
dashboardUrl?: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
const translations: Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
preview: string;
|
||||
greeting: string;
|
||||
orderReceived: string;
|
||||
orderNumber: string;
|
||||
items: string;
|
||||
quantity: string;
|
||||
total: string;
|
||||
shippingTo: string;
|
||||
questions: string;
|
||||
thankYou: string;
|
||||
adminTitle: string;
|
||||
adminPreview: string;
|
||||
adminGreeting: string;
|
||||
adminMessage: string;
|
||||
customerLabel: string;
|
||||
customerEmailLabel: string;
|
||||
billingAddressLabel: string;
|
||||
phoneLabel: string;
|
||||
viewDashboard: string;
|
||||
}
|
||||
> = {
|
||||
sr: {
|
||||
title: "Potvrda narudžbine",
|
||||
preview: "Vaša narudžbina je potvrđena",
|
||||
greeting: "Poštovani {name},",
|
||||
orderReceived: "Zahvaljujemo se na Vašoj narudžbini! Primili smo je i sada je u pripremi.",
|
||||
orderNumber: "Broj narudžbine",
|
||||
items: "Artikli",
|
||||
quantity: "Količina",
|
||||
total: "Ukupno",
|
||||
shippingTo: "Adresa za dostavu",
|
||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||
thankYou: "Hvala Vam što kupujete kod nas!",
|
||||
adminTitle: "Nova narudžbina!",
|
||||
adminPreview: "Nova narudžbina je primljena",
|
||||
adminGreeting: "Čestitamo na prodaji!",
|
||||
adminMessage: "Nova narudžbina je upravo primljena. Detalji su ispod:",
|
||||
customerLabel: "Kupac",
|
||||
customerEmailLabel: "Email kupca",
|
||||
billingAddressLabel: "Adresa za naplatu",
|
||||
phoneLabel: "Telefon",
|
||||
viewDashboard: "Pogledaj u Dashboardu",
|
||||
},
|
||||
en: {
|
||||
title: "Order Confirmation",
|
||||
preview: "Your order has been confirmed",
|
||||
greeting: "Dear {name},",
|
||||
orderReceived:
|
||||
"Thank you for your order! We have received it and it is now being processed.",
|
||||
orderNumber: "Order number",
|
||||
items: "Items",
|
||||
quantity: "Quantity",
|
||||
total: "Total",
|
||||
shippingTo: "Shipping address",
|
||||
questions: "Questions? Email us at support@manoonoils.com",
|
||||
thankYou: "Thank you for shopping with us!",
|
||||
adminTitle: "New Order! 🎉",
|
||||
adminPreview: "A new order has been received",
|
||||
adminGreeting: "Congratulations on the sale!",
|
||||
adminMessage: "A new order has just been placed. Details below:",
|
||||
customerLabel: "Customer",
|
||||
customerEmailLabel: "Customer Email",
|
||||
billingAddressLabel: "Billing Address",
|
||||
phoneLabel: "Phone",
|
||||
viewDashboard: "View in Dashboard",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderConfirmation({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerEmail,
|
||||
customerName,
|
||||
items,
|
||||
total,
|
||||
shippingAddress,
|
||||
billingAddress,
|
||||
phone,
|
||||
siteUrl,
|
||||
dashboardUrl,
|
||||
isAdmin = false,
|
||||
}: OrderConfirmationProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
const adminT = translations["en"];
|
||||
|
||||
if (isAdmin) {
|
||||
return (
|
||||
<BaseLayout previewText={adminT.adminPreview} language="en" siteUrl={siteUrl}>
|
||||
<Text style={styles.title}>{adminT.adminTitle}</Text>
|
||||
<Text style={styles.greeting}>{adminT.adminGreeting}</Text>
|
||||
<Text style={styles.text}>{adminT.adminMessage}</Text>
|
||||
|
||||
<Section style={styles.orderInfo}>
|
||||
<Text style={styles.orderNumber}>
|
||||
<strong>{adminT.orderNumber}:</strong> {orderNumber}
|
||||
</Text>
|
||||
<Text style={styles.customerInfo}>
|
||||
<strong>{adminT.customerLabel}:</strong> {customerName}
|
||||
</Text>
|
||||
<Text style={styles.customerInfo}>
|
||||
<strong>{adminT.customerEmailLabel}:</strong> {customerEmail}
|
||||
</Text>
|
||||
{phone && (
|
||||
<Text style={styles.customerInfo}>
|
||||
<strong>{adminT.phoneLabel}:</strong> {phone}
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{adminT.items}</Text>
|
||||
<Hr style={styles.hr} />
|
||||
{items.map((item) => (
|
||||
<Section key={item.id} style={styles.itemRow}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.quantity}x {item.name}
|
||||
</Text>
|
||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||
</Section>
|
||||
))}
|
||||
<Hr style={styles.hr} />
|
||||
<Section style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>{adminT.total}:</Text>
|
||||
<Text style={styles.totalValue}>{total}</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
{shippingAddress && (
|
||||
<Section style={styles.shippingSection}>
|
||||
<Text style={styles.sectionTitle}>{adminT.shippingTo}</Text>
|
||||
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{billingAddress && (
|
||||
<Section style={styles.shippingSection}>
|
||||
<Text style={styles.sectionTitle}>{adminT.billingAddressLabel}</Text>
|
||||
<Text style={styles.shippingAddress}>{billingAddress}</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={`${dashboardUrl}/orders/${orderId}`} style={styles.button}>
|
||||
{adminT.viewDashboard}
|
||||
</Button>
|
||||
</Section>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||
<Text style={styles.title}>{t.title}</Text>
|
||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||
<Text style={styles.text}>{t.orderReceived}</Text>
|
||||
|
||||
<Section style={styles.orderInfo}>
|
||||
<Text style={styles.orderNumber}>
|
||||
<strong>{t.orderNumber}:</strong> {orderNumber}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{t.items}</Text>
|
||||
<Hr style={styles.hr} />
|
||||
{items.map((item) => (
|
||||
<Section key={item.id} style={styles.itemRow}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.quantity}x {item.name}
|
||||
</Text>
|
||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||
</Section>
|
||||
))}
|
||||
<Hr style={styles.hr} />
|
||||
<Section style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>{t.total}:</Text>
|
||||
<Text style={styles.totalValue}>{total}</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
{shippingAddress && (
|
||||
<Section style={styles.shippingSection}>
|
||||
<Text style={styles.sectionTitle}>{t.shippingTo}</Text>
|
||||
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={siteUrl} style={styles.button}>
|
||||
{language === "sr" ? "Pogledajte narudžbinu" : "View Order"}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={styles.questions}>{t.questions}</Text>
|
||||
<Text style={styles.thankYou}>{t.thankYou}</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
greeting: {
|
||||
fontSize: "16px",
|
||||
color: "#333333",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
text: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderInfo: {
|
||||
backgroundColor: "#f9f9f9",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderNumber: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0 0 8px 0",
|
||||
},
|
||||
customerInfo: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0 0 4px 0",
|
||||
},
|
||||
itemsSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e0e0e0",
|
||||
margin: "10px 0",
|
||||
},
|
||||
itemRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
itemName: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
itemPrice: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
totalRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
margin: "0",
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
margin: "0",
|
||||
},
|
||||
shippingSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
shippingAddress: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
},
|
||||
buttonSection: {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "20px",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "12px 30px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
textDecoration: "none",
|
||||
},
|
||||
questions: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
thankYou: {
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Button, Hr, Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
}
|
||||
|
||||
interface OrderShippedProps {
|
||||
language?: string;
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerName: string;
|
||||
items: OrderItem[];
|
||||
trackingNumber?: string;
|
||||
trackingUrl?: string;
|
||||
siteUrl: string;
|
||||
}
|
||||
|
||||
const translations: Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
preview: string;
|
||||
greeting: string;
|
||||
orderShipped: string;
|
||||
tracking: string;
|
||||
items: string;
|
||||
questions: string;
|
||||
}
|
||||
> = {
|
||||
sr: {
|
||||
title: "Vaša narudžbina je poslata!",
|
||||
preview: "Vaša narudžbina je na putu",
|
||||
greeting: "Poštovani {name},",
|
||||
orderShipped:
|
||||
"Odlične vesti! Vaša narudžbina je poslata i uskoro će stići na vašu adresu.",
|
||||
tracking: "Praćenje pošiljke",
|
||||
items: "Artikli",
|
||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||
},
|
||||
en: {
|
||||
title: "Your Order Has Shipped!",
|
||||
preview: "Your order is on its way",
|
||||
greeting: "Dear {name},",
|
||||
orderShipped:
|
||||
"Great news! Your order has been shipped and will arrive at your address soon.",
|
||||
tracking: "Track your shipment",
|
||||
items: "Items",
|
||||
questions: "Questions? Email us at support@manoonoils.com",
|
||||
},
|
||||
de: {
|
||||
title: "Ihre Bestellung wurde versendet!",
|
||||
preview: "Ihre Bestellung ist unterwegs",
|
||||
greeting: "Sehr geehrte/r {name},",
|
||||
orderShipped:
|
||||
"Großartige Neuigkeiten! Ihre Bestellung wurde versandt und wird in Kürze bei Ihnen eintreffen.",
|
||||
tracking: "Sendung verfolgen",
|
||||
items: "Artikel",
|
||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||
},
|
||||
fr: {
|
||||
title: "Votre commande a été expédiée!",
|
||||
preview: "Votre commande est en route",
|
||||
greeting: "Cher(e) {name},",
|
||||
orderShipped:
|
||||
"Bonne nouvelle! Votre commande a été expédiée et arrivera bientôt à votre adresse.",
|
||||
tracking: "Suivre votre envoi",
|
||||
items: "Articles",
|
||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderShipped({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerName,
|
||||
items,
|
||||
trackingNumber,
|
||||
trackingUrl,
|
||||
siteUrl,
|
||||
}: OrderShippedProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||
<Text style={styles.title}>{t.title}</Text>
|
||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||
<Text style={styles.text}>{t.orderShipped}</Text>
|
||||
|
||||
{trackingNumber && (
|
||||
<Section style={styles.trackingSection}>
|
||||
<Text style={styles.sectionTitle}>{t.tracking}</Text>
|
||||
{trackingUrl ? (
|
||||
<Button href={trackingUrl} style={styles.trackingButton}>
|
||||
{trackingNumber}
|
||||
</Button>
|
||||
) : (
|
||||
<Text style={styles.trackingNumber}>{trackingNumber}</Text>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{t.items}</Text>
|
||||
<Hr style={styles.hr} />
|
||||
{items.map((item) => (
|
||||
<Section key={item.id} style={styles.itemRow}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.quantity}x {item.name}
|
||||
</Text>
|
||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||
</Section>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Text style={styles.questions}>{t.questions}</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
greeting: {
|
||||
fontSize: "16px",
|
||||
color: "#333333",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
text: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
trackingSection: {
|
||||
backgroundColor: "#f9f9f9",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
trackingNumber: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
trackingButton: {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "10px 20px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
textDecoration: "none",
|
||||
},
|
||||
itemsSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e0e0e0",
|
||||
margin: "10px 0",
|
||||
},
|
||||
itemRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
itemName: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
itemPrice: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
questions: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export { BaseLayout } from "./BaseLayout";
|
||||
export { OrderConfirmation } from "./OrderConfirmation";
|
||||
export { OrderShipped } from "./OrderShipped";
|
||||
export { OrderCancelled } from "./OrderCancelled";
|
||||
@@ -1,7 +1,10 @@
|
||||
// Minimal GraphQL client placeholder
|
||||
// This is used by the Saleor App SDK but not by our email functionality
|
||||
import { cacheExchange, createClient as urqlCreateClient, fetchExchange } from "urql";
|
||||
|
||||
export const createClient = (url: string, getAuth: () => { token: string }) => ({
|
||||
export const createClient = (url: string, getAuth: () => Promise<{ token: string }>) =>
|
||||
urqlCreateClient({
|
||||
url,
|
||||
exchanges: [],
|
||||
exchanges: [
|
||||
cacheExchange,
|
||||
fetchExchange,
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
export { getOrderCreatedEmails } from "./order-created";
|
||||
export { getOrderShippedEmails } from "./order-shipped";
|
||||
export { getOrderCancelledEmails } from "./order-cancelled";
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Order Cancelled Email Template
|
||||
*
|
||||
* Refactored to use the abstracted email service architecture.
|
||||
*/
|
||||
|
||||
import Handlebars from "handlebars";
|
||||
import { emailServiceConfig, DEFAULT_LANGUAGE } from "@/lib/email/config";
|
||||
|
||||
// Cancelled order specific translations
|
||||
const cancelledTranslations = {
|
||||
en: {
|
||||
subject: "Order {{orderNumber}} Cancelled",
|
||||
title: "Order Cancelled",
|
||||
greeting: "Dear {{customerName}},",
|
||||
body: "Your order has been cancelled. If you have any questions, please contact us.",
|
||||
orderLabel: "Order:",
|
||||
dateLabel: "Date:",
|
||||
reasonLabel: "Reason:",
|
||||
noReason: "No reason provided",
|
||||
itemsLabel: "Items",
|
||||
contactButton: "Contact Us",
|
||||
thankYou: "Thank you!",
|
||||
companyName: emailServiceConfig.companyName,
|
||||
footerText: emailServiceConfig.fromEmail,
|
||||
},
|
||||
sr: {
|
||||
subject: "Porudžbina {{orderNumber}} Otkazana",
|
||||
title: "Porudžbina Otkazana",
|
||||
greeting: "Poštovani {{customerName}},",
|
||||
body: "Vaša porudžbina je otkazana. Ako imate pitanja, molimo vas da nas kontaktirate.",
|
||||
orderLabel: "Porudžbina:",
|
||||
dateLabel: "Datum:",
|
||||
reasonLabel: "Razlog:",
|
||||
noReason: "Razlog nije naveden",
|
||||
itemsLabel: "Artikli",
|
||||
contactButton: "Kontaktirajte Nas",
|
||||
thankYou: "Hvala vam!",
|
||||
companyName: emailServiceConfig.companyName,
|
||||
footerText: emailServiceConfig.fromEmail,
|
||||
},
|
||||
de: {
|
||||
subject: "Bestellung {{orderNumber}} Storniert",
|
||||
title: "Bestellung Storniert",
|
||||
greeting: "Sehr geehrte/r {{customerName}},",
|
||||
body: "Ihre Bestellung wurde storniert. Bei Fragen kontaktieren Sie uns bitte.",
|
||||
orderLabel: "Bestellung:",
|
||||
dateLabel: "Datum:",
|
||||
reasonLabel: "Grund:",
|
||||
noReason: "Kein Grund angegeben",
|
||||
itemsLabel: "Artikel",
|
||||
contactButton: "Kontaktieren Sie Uns",
|
||||
thankYou: "Vielen Dank!",
|
||||
companyName: emailServiceConfig.companyName,
|
||||
footerText: emailServiceConfig.fromEmail,
|
||||
},
|
||||
fr: {
|
||||
subject: "Commande {{orderNumber}} Annulée",
|
||||
title: "Commande Annulée",
|
||||
greeting: "Cher/Chère {{customerName}},",
|
||||
body: "Votre commande a été annulée. Si vous avez des questions, veuillez nous contacter.",
|
||||
orderLabel: "Commande:",
|
||||
dateLabel: "Date:",
|
||||
reasonLabel: "Raison:",
|
||||
noReason: "Aucune raison fournie",
|
||||
itemsLabel: "Articles",
|
||||
contactButton: "Contactez-Nous",
|
||||
thankYou: "Merci!",
|
||||
companyName: emailServiceConfig.companyName,
|
||||
footerText: emailServiceConfig.fromEmail,
|
||||
},
|
||||
};
|
||||
|
||||
// Template
|
||||
const cancelledEmailTemplate = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f6f6f6;margin:0;padding:20px;">
|
||||
<div style="max-width:600px;margin:0 auto;background:white;padding:40px 20px;">
|
||||
<div style="text-align:center;margin-bottom:30px;">
|
||||
<img src="{{companyLogoUrl}}" width="150" alt="{{companyName}}">
|
||||
</div>
|
||||
|
||||
<h1 style="font-size:24px;font-weight:bold;color:#1a1a1a;margin-bottom:20px;">{{title}}</h1>
|
||||
|
||||
<p style="font-size:16px;color:#333;margin-bottom:10px;">{{greeting}}</p>
|
||||
<p style="font-size:14px;color:#666;margin-bottom:20px;">{{body}}</p>
|
||||
|
||||
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{orderLabel}}</strong> {{orderNumber}}</p>
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{dateLabel}}</strong> {{orderDate}}</p>
|
||||
<p style="font-size:14px;color:#333;margin:0;"><strong>{{reasonLabel}}</strong> {{cancellationReason}}</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin-bottom:10px;">{{itemsLabel}}</p>
|
||||
<div style="margin-bottom:20px;">
|
||||
{{#each items}}
|
||||
<p style="font-size:14px;color:#333;margin:0 0 4px 0;">{{quantity}}x {{name}} - {{price}}</p>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin:20px 0;">
|
||||
<a href="{{storefrontUrl}}" style="display:inline-block;background:#000;color:white;padding:12px 30px;border-radius:4px;text-decoration:none;font-size:14px;font-weight:bold;">{{contactButton}}</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size:14px;color:#666;margin-bottom:10px;">{{footerText}}</p>
|
||||
<p style="font-size:14px;font-weight:bold;color:#1a1a1a;">{{thankYou}}</p>
|
||||
|
||||
<div style="margin-top:40px;padding-top:20px;border-top:1px solid #e0e0e0;text-align:center;color:#666;font-size:12px;">
|
||||
<p>{{companyName}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const compileCancelledEmail = Handlebars.compile(cancelledEmailTemplate);
|
||||
|
||||
export function getOrderCancelledEmails(order: any) {
|
||||
const lang = (order.languageCode?.toLowerCase() || DEFAULT_LANGUAGE) as keyof typeof cancelledTranslations;
|
||||
const t = cancelledTranslations[lang] || cancelledTranslations[DEFAULT_LANGUAGE];
|
||||
|
||||
const customerName = order.shippingAddress?.firstName
|
||||
? `${order.shippingAddress.firstName} ${order.shippingAddress.lastName || ""}`.trim()
|
||||
: order.userEmail?.split("@")[0] || "Customer";
|
||||
|
||||
const currency = order.total?.gross?.currency || "EUR";
|
||||
const orderDate = order.created
|
||||
? new Date(order.created).toLocaleString(lang === "sr" ? "sr-RS" : lang === "de" ? "de-DE" : lang === "fr" ? "fr-FR" : "en-US")
|
||||
: new Date().toLocaleString();
|
||||
const cancellationReason = order.metadata?.find((m: { key: string; value: string }) => m.key === "cancellation_reason")?.value || t.noReason;
|
||||
|
||||
const greeting = Handlebars.compile(t.greeting)({ customerName });
|
||||
const subject = Handlebars.compile(t.subject)({ orderNumber: order.number || order.id });
|
||||
|
||||
const items = (order.lines || []).map((line: any) => ({
|
||||
name: line.variant?.product?.name || line.variant?.name || "Product",
|
||||
quantity: line.quantity || 0,
|
||||
price: `${line.totalPrice?.gross?.amount?.toFixed(2) || "0.00"} ${currency}`
|
||||
}));
|
||||
|
||||
const emailData = {
|
||||
...t,
|
||||
greeting,
|
||||
orderNumber: order.number || order.id,
|
||||
orderDate,
|
||||
cancellationReason,
|
||||
items,
|
||||
companyLogoUrl: emailServiceConfig.companyLogoUrl,
|
||||
storefrontUrl: emailServiceConfig.storefrontUrl,
|
||||
};
|
||||
|
||||
return {
|
||||
customerSubject: subject,
|
||||
customerHtml: compileCancelledEmail(emailData),
|
||||
adminSubject: `Order Cancelled #${order.number || order.id}`,
|
||||
adminHtml: compileCancelledEmail({
|
||||
...emailData,
|
||||
title: `Order Cancelled - #${order.number || order.id}`,
|
||||
body: `Order has been cancelled. Customer: ${order.userEmail}. Reason: ${cancellationReason}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Order Created Email Template
|
||||
*
|
||||
* This module is now a thin wrapper around the abstracted email service architecture.
|
||||
* All hardcoded values have been moved to configuration files.
|
||||
*
|
||||
* Architecture:
|
||||
* - types.ts: Type definitions
|
||||
* - config.ts: All configurable values (translations, URLs, etc.)
|
||||
* - services/paymentMethodService.ts: Payment method detection logic
|
||||
* - services/orderTransformerService.ts: Order data transformation
|
||||
* - services/emailBuilderService.ts: Email template compilation
|
||||
*/
|
||||
|
||||
import { EmailBuilderService } from "@/lib/email/services/emailBuilderService";
|
||||
import { emailServiceConfig } from "@/lib/email/config";
|
||||
|
||||
/**
|
||||
* Generate order created emails (customer + admin)
|
||||
*
|
||||
* @param order - Saleor order object
|
||||
* @returns EmailResult with customer and admin email content
|
||||
*
|
||||
* @example
|
||||
* const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderCreatedEmails(order);
|
||||
*/
|
||||
export function getOrderCreatedEmails(order: any) {
|
||||
return EmailBuilderService.buildOrderCreatedEmails(order, {
|
||||
companyLogoUrl: emailServiceConfig.companyLogoUrl,
|
||||
companyName: emailServiceConfig.companyName,
|
||||
storefrontUrl: emailServiceConfig.storefrontUrl,
|
||||
dashboardUrl: emailServiceConfig.dashboardUrl,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-export types for consumers
|
||||
export type { EmailResult } from "@/lib/email/types";
|
||||
@@ -0,0 +1,218 @@
|
||||
import Handlebars from "handlebars";
|
||||
|
||||
// Translations for Order Shipped emails
|
||||
const translations = {
|
||||
en: {
|
||||
subject: "Order {{orderNumber}} Has Shipped!",
|
||||
title: "Your Order Has Shipped!",
|
||||
greeting: "Dear {{customerName}},",
|
||||
body: "Great news! Your order has been shipped and is on its way.",
|
||||
orderLabel: "Order:",
|
||||
dateLabel: "Date:",
|
||||
trackingLabel: "Tracking Number:",
|
||||
itemsLabel: "Items",
|
||||
trackButton: "Track Your Order",
|
||||
thankYou: "Thank you!",
|
||||
companyName: "ManoonOils",
|
||||
footerText: "support@manoonoils.com"
|
||||
},
|
||||
sr: {
|
||||
subject: "Porudžbina {{orderNumber}} Poslata!",
|
||||
title: "Vaša Porudžbina je Poslata!",
|
||||
greeting: "Poštovani {{customerName}},",
|
||||
body: "Odlične vesti! Vaša porudžbina je poslata i na putu je.",
|
||||
orderLabel: "Porudžbina:",
|
||||
dateLabel: "Datum:",
|
||||
trackingLabel: "Broj Za Praćenje:",
|
||||
itemsLabel: "Artikli",
|
||||
trackButton: "Pratite Porudžbinu",
|
||||
thankYou: "Hvala vam!",
|
||||
companyName: "ManoonOils",
|
||||
footerText: "support@manoonoils.com"
|
||||
},
|
||||
de: {
|
||||
subject: "Bestellung {{orderNumber}} Wurde Versendet!",
|
||||
title: "Ihre Bestellung Wurde Versendet!",
|
||||
greeting: "Sehr geehrte/r {{customerName}},",
|
||||
body: "Tolle Neuigkeiten! Ihre Bestellung wurde versendet und ist unterwegs.",
|
||||
orderLabel: "Bestellung:",
|
||||
dateLabel: "Datum:",
|
||||
trackingLabel: "Sendungsnummer:",
|
||||
itemsLabel: "Artikel",
|
||||
trackButton: "Bestellung Verfolgen",
|
||||
thankYou: "Vielen Dank!",
|
||||
companyName: "ManoonOils",
|
||||
footerText: "support@manoonoils.com"
|
||||
},
|
||||
fr: {
|
||||
subject: "Commande {{orderNumber}} Expédiée!",
|
||||
title: "Votre Commande a été Expédiée!",
|
||||
greeting: "Cher/Chère {{customerName}},",
|
||||
body: "Bonne nouvelle! Votre commande a été expédiée et est en route.",
|
||||
orderLabel: "Commande:",
|
||||
dateLabel: "Date:",
|
||||
trackingLabel: "Numéro de Suivi:",
|
||||
itemsLabel: "Articles",
|
||||
trackButton: "Suivre la Commande",
|
||||
thankYou: "Merci!",
|
||||
companyName: "ManoonOils",
|
||||
footerText: "support@manoonoils.com"
|
||||
}
|
||||
};
|
||||
|
||||
// Customer email template
|
||||
const customerEmailTemplate = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f6f6f6;margin:0;padding:20px;">
|
||||
<div style="max-width:600px;margin:0 auto;background:white;padding:40px 20px;">
|
||||
<div style="text-align:center;margin-bottom:30px;">
|
||||
<img src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png" width="150" alt="{{companyName}}">
|
||||
</div>
|
||||
|
||||
<h1 style="font-size:24px;font-weight:bold;color:#1a1a1a;margin-bottom:20px;">{{title}}</h1>
|
||||
|
||||
<p style="font-size:16px;color:#333;margin-bottom:10px;">{{greeting}}</p>
|
||||
<p style="font-size:14px;color:#666;margin-bottom:20px;">{{body}}</p>
|
||||
|
||||
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{orderLabel}}</strong> {{orderNumber}}</p>
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{dateLabel}}</strong> {{orderDate}}</p>
|
||||
{{#if trackingNumber}}
|
||||
<p style="font-size:14px;color:#333;margin:0;"><strong>{{trackingLabel}}</strong> {{trackingNumber}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin-bottom:10px;">{{itemsLabel}}</p>
|
||||
<div style="margin-bottom:20px;">
|
||||
{{#each items}}
|
||||
<p style="font-size:14px;color:#333;margin:0 0 4px 0;">{{quantity}}x {{name}} - {{price}}</p>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
{{#if trackingUrl}}
|
||||
<div style="text-align:center;margin:20px 0;">
|
||||
<a href="{{trackingUrl}}" style="display:inline-block;background:#000;color:white;padding:12px 30px;border-radius:4px;text-decoration:none;font-size:14px;font-weight:bold;">{{trackButton}}</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<p style="font-size:14px;color:#666;margin-bottom:10px;">{{footerText}}</p>
|
||||
<p style="font-size:14px;font-weight:bold;color:#1a1a1a;">{{thankYou}}</p>
|
||||
|
||||
<div style="margin-top:40px;padding-top:20px;border-top:1px solid #e0e0e0;text-align:center;color:#666;font-size:12px;">
|
||||
<p>{{companyName}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
// Admin email template
|
||||
const adminEmailTemplate = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f6f6f6;margin:0;padding:20px;">
|
||||
<div style="max-width:600px;margin:0 auto;background:white;padding:40px 20px;">
|
||||
<h1 style="font-size:24px;font-weight:bold;color:#1a1a1a;margin-bottom:20px;">Order Shipped</h1>
|
||||
|
||||
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
|
||||
<h3 style="font-size:16px;font-weight:bold;color:#1a1a1a;margin:0 0 10px 0;">Order #{{orderNumber}}</h3>
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>Date:</strong> {{orderDate}}</p>
|
||||
{{#if trackingNumber}}
|
||||
<p style="font-size:14px;color:#333;margin:0;"><strong>Tracking:</strong> {{trackingNumber}}</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
|
||||
<h3 style="font-size:16px;font-weight:bold;color:#1a1a1a;margin:0 0 10px 0;">Customer</h3>
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>Name:</strong> {{customerName}}</p>
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>Email:</strong> {{customerEmail}}</p>
|
||||
<p style="font-size:14px;color:#333;margin:0;"><strong>Phone:</strong> {{#if phone}}{{phone}}{{else}}Not provided{{/if}}</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin-bottom:10px;">Items</p>
|
||||
<div style="margin-bottom:20px;">
|
||||
{{#each items}}
|
||||
<p style="font-size:14px;color:#333;margin:0 0 4px 0;">{{quantity}}x {{name}} - {{price}}</p>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;margin:20px 0;">
|
||||
<a href="https://dashboard.manoonoils.com/orders" style="display:inline-block;background:#000;color:white;padding:12px 30px;border-radius:4px;text-decoration:none;font-size:14px;font-weight:bold;">Dashboard</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
// Compile templates
|
||||
const compileCustomerEmail = Handlebars.compile(customerEmailTemplate);
|
||||
const compileAdminEmail = Handlebars.compile(adminEmailTemplate);
|
||||
|
||||
export function getOrderShippedEmails(order: any, fulfillment?: any) {
|
||||
// Detect language
|
||||
const lang = order.languageCode?.toLowerCase() || "en";
|
||||
const t = translations[lang as keyof typeof translations] || translations.en;
|
||||
|
||||
// Format data
|
||||
const customerName = order.shippingAddress?.firstName
|
||||
? `${order.shippingAddress.firstName} ${order.shippingAddress.lastName || ""}`.trim()
|
||||
: order.userEmail?.split("@")[0] || "Customer";
|
||||
|
||||
const currency = order.total?.gross?.currency || "EUR";
|
||||
|
||||
const items = (order.lines || []).map((line: any) => ({
|
||||
name: line.variant?.product?.name || line.variant?.name || "Product",
|
||||
quantity: line.quantity || 0,
|
||||
price: `${line.totalPrice?.gross?.amount?.toFixed(2) || "0.00"} ${currency}`
|
||||
}));
|
||||
|
||||
const orderDate = order.created
|
||||
? new Date(order.created).toLocaleString(lang === "sr" ? "sr-RS" : lang === "de" ? "de-DE" : lang === "fr" ? "fr-FR" : "en-US")
|
||||
: new Date().toLocaleString();
|
||||
|
||||
// Get tracking info from fulfillment
|
||||
const trackingNumber = fulfillment?.trackingNumber || "";
|
||||
const trackingUrl = trackingNumber
|
||||
? `https://track.manoonoils.com/${trackingNumber}`
|
||||
: "https://manoonoils.com";
|
||||
|
||||
// Compile greeting with customer name
|
||||
const greeting = Handlebars.compile(t.greeting)({ customerName });
|
||||
|
||||
// Customer email data
|
||||
const customerData = {
|
||||
...t,
|
||||
greeting,
|
||||
orderNumber: order.number || order.id,
|
||||
customerName,
|
||||
orderDate,
|
||||
trackingNumber,
|
||||
trackingUrl,
|
||||
items
|
||||
};
|
||||
|
||||
// Admin email data (English)
|
||||
const adminData = {
|
||||
orderNumber: order.number || order.id,
|
||||
customerName,
|
||||
customerEmail: order.userEmail || "",
|
||||
orderDate,
|
||||
trackingNumber,
|
||||
phone: order.shippingAddress?.phone || "",
|
||||
items
|
||||
};
|
||||
|
||||
return {
|
||||
customerSubject: Handlebars.compile(t.subject)({ orderNumber: order.number || order.id }),
|
||||
customerHtml: compileCustomerEmail(customerData),
|
||||
adminSubject: `Order Shipped #${order.number || order.id}`,
|
||||
adminHtml: compileAdminEmail(adminData)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Email Configuration - All configurable values extracted from hardcoded locations
|
||||
* This makes the system configurable without changing code
|
||||
*/
|
||||
|
||||
import type {
|
||||
LanguageCode,
|
||||
EmailTemplateConfig,
|
||||
AdminEmailTemplateConfig,
|
||||
PaymentMethodConfig,
|
||||
EmailServiceConfig
|
||||
} from "./types";
|
||||
|
||||
// Payment method configurations - easily extendable
|
||||
export const paymentMethodConfigs: Record<string, PaymentMethodConfig> = {
|
||||
card: {
|
||||
id: "card",
|
||||
label: "Card",
|
||||
},
|
||||
cod: {
|
||||
id: "cod",
|
||||
label: "Cash on Delivery",
|
||||
},
|
||||
paypal: {
|
||||
id: "paypal",
|
||||
label: "PayPal",
|
||||
},
|
||||
bank_transfer: {
|
||||
id: "bank_transfer",
|
||||
label: "Bank Transfer",
|
||||
},
|
||||
unknown: {
|
||||
id: "unknown",
|
||||
label: "Unknown",
|
||||
},
|
||||
};
|
||||
|
||||
// Customer email translations - configuration driven
|
||||
export const emailTranslations: Record<LanguageCode, EmailTemplateConfig> = {
|
||||
en: {
|
||||
subject: "Order {{orderNumber}} Confirmed",
|
||||
title: "Order Confirmed",
|
||||
greeting: "Dear {{customerName}},",
|
||||
body: "Thank you for your order! We have received it and it is now being processed.",
|
||||
orderLabel: "Order:",
|
||||
dateLabel: "Date:",
|
||||
statusLabel: "Status:",
|
||||
itemsLabel: "Items",
|
||||
subtotalLabel: "Subtotal:",
|
||||
shippingLabel: "Shipping:",
|
||||
taxLabel: "Tax:",
|
||||
totalLabel: "Total:",
|
||||
shippingAddressLabel: "Shipping Address",
|
||||
phoneLabel: "Phone:",
|
||||
phoneNotProvided: "Not provided",
|
||||
viewOrderButton: "View Order",
|
||||
thankYou: "Thank you!",
|
||||
companyName: "ManoonOils",
|
||||
footerText: "support@manoonoils.com",
|
||||
},
|
||||
sr: {
|
||||
subject: "Porudžbina {{orderNumber}} Potvrđena",
|
||||
title: "Porudžbina Potvrđena",
|
||||
greeting: "Poštovani {{customerName}},",
|
||||
body: "Hvala vam na porudžbini! Primili smo je i sada je obrađujemo.",
|
||||
orderLabel: "Porudžbina:",
|
||||
dateLabel: "Datum:",
|
||||
statusLabel: "Status:",
|
||||
itemsLabel: "Artikli",
|
||||
subtotalLabel: "Međuzbir:",
|
||||
shippingLabel: "Dostava:",
|
||||
taxLabel: "Porez:",
|
||||
totalLabel: "Ukupno:",
|
||||
shippingAddressLabel: "Adresa za Dostavu",
|
||||
phoneLabel: "Telefon:",
|
||||
phoneNotProvided: "Nije navedeno",
|
||||
viewOrderButton: "Pogledaj Porudžbinu",
|
||||
thankYou: "Hvala vam!",
|
||||
companyName: "ManoonOils",
|
||||
footerText: "support@manoonoils.com",
|
||||
},
|
||||
de: {
|
||||
subject: "Bestellung {{orderNumber}} Bestätigt",
|
||||
title: "Bestellung Bestätigt",
|
||||
greeting: "Sehr geehrte/r {{customerName}},",
|
||||
body: "Vielen Dank für Ihre Bestellung! Wir haben sie erhalten und bearbeiten sie jetzt.",
|
||||
orderLabel: "Bestellung:",
|
||||
dateLabel: "Datum:",
|
||||
statusLabel: "Status:",
|
||||
itemsLabel: "Artikel",
|
||||
subtotalLabel: "Zwischensumme:",
|
||||
shippingLabel: "Versand:",
|
||||
taxLabel: "Steuer:",
|
||||
totalLabel: "Gesamt:",
|
||||
shippingAddressLabel: "Lieferadresse",
|
||||
phoneLabel: "Telefon:",
|
||||
phoneNotProvided: "Nicht angegeben",
|
||||
viewOrderButton: "Bestellung Ansehen",
|
||||
thankYou: "Vielen Dank!",
|
||||
companyName: "ManoonOils",
|
||||
footerText: "support@manoonoils.com",
|
||||
},
|
||||
fr: {
|
||||
subject: "Commande {{orderNumber}} Confirmée",
|
||||
title: "Commande Confirmée",
|
||||
greeting: "Cher/Chère {{customerName}},",
|
||||
body: "Merci pour votre commande! Nous l'avons reçue et elle est en cours de traitement.",
|
||||
orderLabel: "Commande:",
|
||||
dateLabel: "Date:",
|
||||
statusLabel: "Statut:",
|
||||
itemsLabel: "Articles",
|
||||
subtotalLabel: "Sous-total:",
|
||||
shippingLabel: "Livraison:",
|
||||
taxLabel: "Taxe:",
|
||||
totalLabel: "Total:",
|
||||
shippingAddressLabel: "Adresse de Livraison",
|
||||
phoneLabel: "Téléphone:",
|
||||
phoneNotProvided: "Non fourni",
|
||||
viewOrderButton: "Voir la Commande",
|
||||
thankYou: "Merci!",
|
||||
companyName: "ManoonOils",
|
||||
footerText: "support@manoonoils.com",
|
||||
},
|
||||
};
|
||||
|
||||
// Admin email translations - always in English but configurable
|
||||
export const adminEmailTranslations: AdminEmailTemplateConfig = {
|
||||
adminTitle: "New Order! 🎉",
|
||||
adminOrderLabel: "Order",
|
||||
customerLabel: "Customer",
|
||||
nameLabel: "Name:",
|
||||
emailLabel: "Email:",
|
||||
dashboardButton: "Dashboard",
|
||||
};
|
||||
|
||||
// Default payment label for admin emails
|
||||
export const adminPaymentConfig = {
|
||||
paymentLabel: "Payment:",
|
||||
};
|
||||
|
||||
// Email service configuration - loaded from environment or defaults
|
||||
export const emailServiceConfig: EmailServiceConfig = {
|
||||
fromEmail: process.env.EMAIL_FROM_ADDRESS || "support@mail.manoonoils.com",
|
||||
fromName: process.env.EMAIL_FROM_NAME || "Manoon Oils",
|
||||
adminEmails: process.env.ADMIN_EMAILS?.split(",") || [
|
||||
"me@hytham.me",
|
||||
"tamara@hytham.me",
|
||||
],
|
||||
companyName: process.env.COMPANY_NAME || "ManoonOils",
|
||||
companyLogoUrl:
|
||||
process.env.COMPANY_LOGO_URL ||
|
||||
"https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png",
|
||||
dashboardUrl: process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com",
|
||||
storefrontUrl: process.env.STOREFRONT_URL || "https://manoonoils.com",
|
||||
trackingUrl: process.env.TRACKING_URL || "https://track.manoonoils.com",
|
||||
};
|
||||
|
||||
// Locale configurations for date formatting
|
||||
export const localeConfigs: Record<LanguageCode, string> = {
|
||||
en: "en-US",
|
||||
sr: "sr-RS",
|
||||
de: "de-DE",
|
||||
fr: "fr-FR",
|
||||
};
|
||||
|
||||
// Default fallback language
|
||||
export const DEFAULT_LANGUAGE: LanguageCode = "en";
|
||||
|
||||
// Default currency
|
||||
export const DEFAULT_CURRENCY = "EUR";
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Email Module - Centralized exports for the email system
|
||||
*/
|
||||
|
||||
// Types
|
||||
export * from "./types";
|
||||
|
||||
// Configuration
|
||||
export * from "./config";
|
||||
|
||||
// Services
|
||||
export { PaymentMethodService } from "./services/paymentMethodService";
|
||||
export { OrderTransformerService } from "./services/orderTransformerService";
|
||||
export { EmailBuilderService } from "./services/emailBuilderService";
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Email Builder Service - Compiles email templates with data
|
||||
* Handles template compilation and rendering
|
||||
*/
|
||||
|
||||
import Handlebars from "handlebars";
|
||||
import type { SaleorOrder, EmailResult } from "../types";
|
||||
import { OrderTransformerService } from "./orderTransformerService";
|
||||
import {
|
||||
emailTranslations,
|
||||
adminEmailTranslations,
|
||||
adminPaymentConfig,
|
||||
DEFAULT_LANGUAGE,
|
||||
} from "../config";
|
||||
|
||||
// Customer email template
|
||||
const customerEmailTemplate = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f6f6f6;margin:0;padding:20px;">
|
||||
<div style="max-width:600px;margin:0 auto;background:white;padding:40px 20px;">
|
||||
<div style="text-align:center;margin-bottom:30px;">
|
||||
<img src="{{companyLogoUrl}}" width="150" alt="{{companyName}}">
|
||||
</div>
|
||||
|
||||
<h1 style="font-size:24px;font-weight:bold;color:#1a1a1a;margin-bottom:20px;">{{title}}</h1>
|
||||
|
||||
<p style="font-size:16px;color:#333;margin-bottom:10px;">{{greeting}}</p>
|
||||
<p style="font-size:14px;color:#666;margin-bottom:20px;">{{body}}</p>
|
||||
|
||||
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{orderLabel}}</strong> {{orderNumber}}</p>
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{dateLabel}}</strong> {{orderDate}}</p>
|
||||
<p style="font-size:14px;color:#333;margin:0;"><strong>{{statusLabel}}</strong> {{orderStatus}}</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin-bottom:10px;">{{itemsLabel}}</p>
|
||||
<div style="margin-bottom:20px;">
|
||||
{{#each items}}
|
||||
<p style="font-size:14px;color:#333;margin:0 0 4px 0;">{{quantity}}x {{name}} - {{price}}</p>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{subtotalLabel}}</strong> {{subtotal}}</p>
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{shippingLabel}}</strong> {{shipping}}</p>
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{taxLabel}}</strong> {{tax}}</p>
|
||||
<hr style="border-color:#e0e0e0;margin:10px 0;">
|
||||
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin:0;"><strong>{{totalLabel}}</strong> {{total}}</p>
|
||||
</div>
|
||||
|
||||
{{#if shippingAddress}}
|
||||
<div style="margin-bottom:20px;">
|
||||
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin-bottom:10px;">{{shippingAddressLabel}}</p>
|
||||
<p style="font-size:14px;color:#666;margin:0;white-space:pre-line;">{{shippingAddress}}</p>
|
||||
<p style="font-size:14px;color:#666;margin:8px 0 0 0;"><strong>{{phoneLabel}}</strong> {{#if phone}}{{phone}}{{else}}{{phoneNotProvided}}{{/if}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div style="text-align:center;margin:20px 0;">
|
||||
<a href="{{storefrontUrl}}" style="display:inline-block;background:#000;color:white;padding:12px 30px;border-radius:4px;text-decoration:none;font-size:14px;font-weight:bold;">{{viewOrderButton}}</a>
|
||||
</div>
|
||||
|
||||
<p style="font-size:14px;color:#666;margin-bottom:10px;">{{footerText}}</p>
|
||||
<p style="font-size:14px;font-weight:bold;color:#1a1a1a;">{{thankYou}}</p>
|
||||
|
||||
<div style="margin-top:40px;padding-top:20px;border-top:1px solid #e0e0e0;text-align:center;color:#666;font-size:12px;">
|
||||
<p>{{companyName}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
// Admin email template
|
||||
const adminEmailTemplate = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f6f6f6;margin:0;padding:20px;">
|
||||
<div style="max-width:600px;margin:0 auto;background:white;padding:40px 20px;">
|
||||
<h1 style="font-size:24px;font-weight:bold;color:#1a1a1a;margin-bottom:20px;">{{adminTitle}}</h1>
|
||||
|
||||
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
|
||||
<h3 style="font-size:16px;font-weight:bold;color:#1a1a1a;margin:0 0 10px 0;">{{adminOrderLabel}} #{{orderNumber}}</h3>
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{dateLabel}}</strong> {{orderDate}}</p>
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{statusLabel}}</strong> {{orderStatus}}</p>
|
||||
<p style="font-size:14px;color:#333;margin:0;"><strong>{{paymentLabel}}</strong> {{paymentMethod}}</p>
|
||||
</div>
|
||||
|
||||
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
|
||||
<h3 style="font-size:16px;font-weight:bold;color:#1a1a1a;margin:0 0 10px 0;">{{customerLabel}}</h3>
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{nameLabel}}</strong> {{customerName}}</p>
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{emailLabel}}</strong> {{customerEmail}}</p>
|
||||
<p style="font-size:14px;color:#333;margin:0;"><strong>{{phoneLabel}}</strong> {{#if phone}}{{phone}}{{else}}{{phoneNotProvided}}{{/if}}</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin-bottom:10px;">{{itemsLabel}}</p>
|
||||
<div style="margin-bottom:20px;">
|
||||
{{#each items}}
|
||||
<p style="font-size:14px;color:#333;margin:0 0 4px 0;">{{quantity}}x {{name}} - {{price}}</p>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div style="background:#f9f9f9;padding:15px;border-radius:8px;margin-bottom:20px;">
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{subtotalLabel}}</strong> {{subtotal}}</p>
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{shippingLabel}}</strong> {{shipping}}</p>
|
||||
<p style="font-size:14px;color:#333;margin:0 0 8px 0;"><strong>{{taxLabel}}</strong> {{tax}}</p>
|
||||
<hr style="border-color:#e0e0e0;margin:10px 0;">
|
||||
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin:0;"><strong>{{totalLabel}}</strong> {{total}}</p>
|
||||
</div>
|
||||
|
||||
{{#if shippingAddress}}
|
||||
<div style="margin-bottom:20px;">
|
||||
<p style="font-size:16px;font-weight:bold;color:#1a1a1a;margin-bottom:10px;">{{shippingAddressLabel}}</p>
|
||||
<p style="font-size:14px;color:#666;margin:0;white-space:pre-line;">{{shippingAddress}}</p>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
<div style="text-align:center;margin:20px 0;">
|
||||
<a href="{{dashboardUrl}}" style="display:inline-block;background:#000;color:white;padding:12px 30px;border-radius:4px;text-decoration:none;font-size:14px;font-weight:bold;">{{dashboardButton}}</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
export class EmailBuilderService {
|
||||
private static compileCustomerEmail = Handlebars.compile(customerEmailTemplate);
|
||||
private static compileAdminEmail = Handlebars.compile(adminEmailTemplate);
|
||||
|
||||
/**
|
||||
* Build order created emails (customer + admin)
|
||||
*/
|
||||
static buildOrderCreatedEmails(
|
||||
order: SaleorOrder,
|
||||
config: {
|
||||
companyLogoUrl: string;
|
||||
companyName: string;
|
||||
storefrontUrl: string;
|
||||
dashboardUrl: string;
|
||||
}
|
||||
): EmailResult {
|
||||
const languageCode = OrderTransformerService.getLanguageCode(order);
|
||||
const translations = emailTranslations[languageCode] || emailTranslations[DEFAULT_LANGUAGE];
|
||||
|
||||
// Compile greeting and subject with Handlebars
|
||||
const greeting = Handlebars.compile(translations.greeting)({
|
||||
customerName: OrderTransformerService.extractCustomerName(order),
|
||||
});
|
||||
const subject = Handlebars.compile(translations.subject)({
|
||||
orderNumber: order.number || order.id,
|
||||
});
|
||||
|
||||
// Build customer email data
|
||||
const customerData = {
|
||||
...OrderTransformerService.transformToEmailData(order, translations),
|
||||
greeting,
|
||||
subject,
|
||||
companyLogoUrl: config.companyLogoUrl,
|
||||
storefrontUrl: config.storefrontUrl,
|
||||
};
|
||||
|
||||
// Build admin email data
|
||||
const adminTranslationsRecord: Record<string, string> = {
|
||||
adminTitle: adminEmailTranslations.adminTitle,
|
||||
adminOrderLabel: adminEmailTranslations.adminOrderLabel,
|
||||
customerLabel: adminEmailTranslations.customerLabel,
|
||||
nameLabel: adminEmailTranslations.nameLabel,
|
||||
emailLabel: adminEmailTranslations.emailLabel,
|
||||
dashboardButton: adminEmailTranslations.dashboardButton,
|
||||
};
|
||||
|
||||
const adminData = {
|
||||
...OrderTransformerService.transformToAdminEmailData(
|
||||
order,
|
||||
translations,
|
||||
adminTranslationsRecord
|
||||
),
|
||||
...adminPaymentConfig,
|
||||
dashboardUrl: config.dashboardUrl,
|
||||
companyLogoUrl: config.companyLogoUrl,
|
||||
};
|
||||
|
||||
return {
|
||||
customerSubject: subject,
|
||||
customerHtml: this.compileCustomerEmail(customerData),
|
||||
adminSubject: `${adminEmailTranslations.adminTitle} #${order.number || order.id}`,
|
||||
adminHtml: this.compileAdminEmail(adminData),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Order Transformer Service - Transforms Saleor order data into normalized email data
|
||||
* All transformation logic abstracted here instead of inline in templates
|
||||
*/
|
||||
|
||||
import type {
|
||||
SaleorOrder,
|
||||
EmailData,
|
||||
AdminEmailData,
|
||||
LanguageCode,
|
||||
OrderLine
|
||||
} from "../types";
|
||||
import {
|
||||
localeConfigs,
|
||||
DEFAULT_LANGUAGE,
|
||||
DEFAULT_CURRENCY
|
||||
} from "../config";
|
||||
import { PaymentMethodService } from "./paymentMethodService";
|
||||
|
||||
export class OrderTransformerService {
|
||||
/**
|
||||
* Extract and format customer name from order
|
||||
*/
|
||||
static extractCustomerName(order: SaleorOrder): string {
|
||||
if (order.shippingAddress?.firstName) {
|
||||
return `${order.shippingAddress.firstName} ${order.shippingAddress.lastName || ""}`.trim();
|
||||
}
|
||||
if (order.billingAddress?.firstName) {
|
||||
return `${order.billingAddress.firstName} ${order.billingAddress.lastName || ""}`.trim();
|
||||
}
|
||||
if (order.userEmail) {
|
||||
return order.userEmail.split("@")[0];
|
||||
}
|
||||
return "Customer";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format shipping address for display
|
||||
*/
|
||||
static formatShippingAddress(order: SaleorOrder): string {
|
||||
const address = order.shippingAddress;
|
||||
if (!address) return "";
|
||||
|
||||
const parts = [
|
||||
`${address.firstName || ""} ${address.lastName || ""}`.trim(),
|
||||
address.streetAddress1,
|
||||
address.streetAddress2,
|
||||
`${address.city || ""}${address.postalCode ? `, ${address.postalCode}` : ""}`,
|
||||
address.country?.country || address.country?.code,
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract phone number with fallback
|
||||
*/
|
||||
static extractPhone(order: SaleorOrder): string {
|
||||
return (
|
||||
order.shippingAddress?.phone ||
|
||||
order.billingAddress?.phone ||
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format order items for email
|
||||
*/
|
||||
static formatItems(order: SaleorOrder): Array<{
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
}> {
|
||||
const currency = order.total?.gross?.currency || DEFAULT_CURRENCY;
|
||||
|
||||
return (order.lines || []).map((line: OrderLine) => {
|
||||
const productName = line.variant?.product?.name || line.variant?.name || "Product";
|
||||
const amount = line.totalPrice?.gross?.amount?.toFixed(2) || "0.00";
|
||||
|
||||
return {
|
||||
name: productName,
|
||||
quantity: line.quantity || 0,
|
||||
price: `${amount} ${currency}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format money amount with currency
|
||||
*/
|
||||
static formatMoney(amount: number | undefined, currency: string): string {
|
||||
const value = amount?.toFixed(2) || "0.00";
|
||||
return `${value} ${currency}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format order date for display
|
||||
*/
|
||||
static formatOrderDate(order: SaleorOrder, languageCode: LanguageCode): string {
|
||||
const date = order.created ? new Date(order.created) : new Date();
|
||||
const locale = localeConfigs[languageCode] || localeConfigs[DEFAULT_LANGUAGE];
|
||||
return date.toLocaleString(locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language code with fallback
|
||||
*/
|
||||
static getLanguageCode(order: SaleorOrder): LanguageCode {
|
||||
const lang = order.languageCode?.toLowerCase() as LanguageCode;
|
||||
if (["en", "sr", "de", "fr"].includes(lang)) {
|
||||
return lang;
|
||||
}
|
||||
return DEFAULT_LANGUAGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform order into email data structure
|
||||
*/
|
||||
static transformToEmailData(
|
||||
order: SaleorOrder,
|
||||
translations: Record<string, any>
|
||||
): EmailData {
|
||||
const languageCode = this.getLanguageCode(order);
|
||||
const currency = order.total?.gross?.currency || DEFAULT_CURRENCY;
|
||||
const customerName = this.extractCustomerName(order);
|
||||
const paymentMethod = PaymentMethodService.detectPaymentMethod(order);
|
||||
|
||||
return {
|
||||
...translations,
|
||||
orderNumber: order.number || order.id,
|
||||
customerName,
|
||||
greeting: "", // Will be compiled by email builder
|
||||
subject: "", // Will be compiled by email builder
|
||||
orderDate: this.formatOrderDate(order, languageCode),
|
||||
orderStatus: order.status || "unfulfilled",
|
||||
items: this.formatItems(order),
|
||||
subtotal: this.formatMoney(order.subtotal?.gross?.amount, currency),
|
||||
shipping: this.formatMoney(order.shippingPrice?.gross?.amount, currency),
|
||||
tax: this.formatMoney(order.total?.tax?.amount, currency),
|
||||
total: this.formatMoney(order.total?.gross?.amount, currency),
|
||||
shippingAddress: this.formatShippingAddress(order),
|
||||
phone: this.extractPhone(order),
|
||||
paymentMethod: PaymentMethodService.getPaymentLabel(paymentMethod),
|
||||
paymentLabel: "Payment:", // Can be moved to config if needed per language
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform order into admin email data structure
|
||||
*/
|
||||
static transformToAdminEmailData(
|
||||
order: SaleorOrder,
|
||||
translations: Record<string, any>,
|
||||
adminTranslations: Record<string, string>
|
||||
): Record<string, any> {
|
||||
const baseData = this.transformToEmailData(order, translations);
|
||||
const paymentMethod = PaymentMethodService.detectPaymentMethod(order);
|
||||
|
||||
return {
|
||||
...baseData,
|
||||
...adminTranslations,
|
||||
customerEmail: order.userEmail || "",
|
||||
paymentMethod: PaymentMethodService.getPaymentLabel(paymentMethod),
|
||||
paymentLabel: "Payment:",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Payment Method Service - Detects payment method from order data
|
||||
* Abstracted logic for determining how an order was paid
|
||||
*/
|
||||
|
||||
import type { SaleorOrder, PaymentMethod } from "../types";
|
||||
import { paymentMethodConfigs } from "../config";
|
||||
|
||||
export class PaymentMethodService {
|
||||
/**
|
||||
* Detect payment method from order transactions and metadata
|
||||
*/
|
||||
static detectPaymentMethod(order: SaleorOrder): PaymentMethod {
|
||||
// Check transactions first
|
||||
if (order.transactions && order.transactions.length > 0) {
|
||||
const transaction = order.transactions[0];
|
||||
const message = transaction.message?.toLowerCase() || "";
|
||||
|
||||
// Check transaction events for payment type
|
||||
const event = transaction.events?.[0];
|
||||
const eventMessage = event?.message?.toLowerCase() || "";
|
||||
const eventType = event?.type?.toLowerCase() || "";
|
||||
|
||||
const combinedText = `${message} ${eventMessage} ${eventType}`;
|
||||
|
||||
if (combinedText.includes("cash") || combinedText.includes("cod")) {
|
||||
return "cod";
|
||||
}
|
||||
if (combinedText.includes("card") || combinedText.includes("stripe")) {
|
||||
return "card";
|
||||
}
|
||||
if (combinedText.includes("paypal")) {
|
||||
return "paypal";
|
||||
}
|
||||
if (combinedText.includes("bank") || combinedText.includes("transfer")) {
|
||||
return "bank_transfer";
|
||||
}
|
||||
}
|
||||
|
||||
// Check metadata for payment method
|
||||
if (order.metadata) {
|
||||
const paymentMeta = order.metadata.find(
|
||||
(m) => m.key === "payment_method" || m.key === "paymentMethod"
|
||||
);
|
||||
if (paymentMeta) {
|
||||
const value = paymentMeta.value.toLowerCase();
|
||||
if (value.includes("cash") || value.includes("cod")) return "cod";
|
||||
if (value.includes("card")) return "card";
|
||||
if (value.includes("paypal")) return "paypal";
|
||||
if (value.includes("bank")) return "bank_transfer";
|
||||
}
|
||||
}
|
||||
|
||||
// Default to unknown if no payment method detected
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display label for a payment method
|
||||
*/
|
||||
static getPaymentLabel(method: PaymentMethod): string {
|
||||
return paymentMethodConfigs[method]?.label || paymentMethodConfigs.unknown.label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full payment method configuration
|
||||
*/
|
||||
static getPaymentConfig(method: PaymentMethod) {
|
||||
return paymentMethodConfigs[method] || paymentMethodConfigs.unknown;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Email Types - Type definitions for the email system
|
||||
* All data structures are strictly typed with no hardcoded values
|
||||
*/
|
||||
|
||||
export type LanguageCode = "en" | "sr" | "de" | "fr";
|
||||
export type PaymentMethod = "card" | "cod" | "paypal" | "bank_transfer" | "unknown";
|
||||
export type OrderStatus = "draft" | "unconfirmed" | "unfulfilled" | "partially_fulfilled" | "fulfilled" | "canceled";
|
||||
|
||||
export interface Money {
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
streetAddress1?: string | null;
|
||||
streetAddress2?: string | null;
|
||||
city?: string | null;
|
||||
postalCode?: string | null;
|
||||
country?: {
|
||||
country?: string | null;
|
||||
code?: string | null;
|
||||
} | null;
|
||||
phone?: string | null;
|
||||
}
|
||||
|
||||
export interface OrderLine {
|
||||
quantity: number;
|
||||
variant?: {
|
||||
name?: string;
|
||||
product?: {
|
||||
name?: string;
|
||||
};
|
||||
};
|
||||
totalPrice?: {
|
||||
gross?: Money;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
id?: string;
|
||||
message?: string;
|
||||
externalUrl?: string;
|
||||
events?: TransactionEvent[];
|
||||
}
|
||||
|
||||
export interface TransactionEvent {
|
||||
id?: string;
|
||||
message?: string;
|
||||
type?: string;
|
||||
amount?: Money;
|
||||
}
|
||||
|
||||
export interface SaleorOrder {
|
||||
id: string;
|
||||
number?: string | null;
|
||||
created?: string | null;
|
||||
status?: string | null;
|
||||
languageCode?: string | null;
|
||||
userEmail?: string | null;
|
||||
shippingAddress?: Address | null;
|
||||
billingAddress?: Address | null;
|
||||
lines?: OrderLine[] | null;
|
||||
subtotal?: {
|
||||
gross?: Money | null;
|
||||
} | null;
|
||||
shippingPrice?: {
|
||||
gross?: Money | null;
|
||||
} | null;
|
||||
total?: {
|
||||
gross?: Money | null;
|
||||
tax?: Money | null;
|
||||
} | null;
|
||||
transactions?: Transaction[] | null;
|
||||
metadata?: Array<{
|
||||
key: string;
|
||||
value: string;
|
||||
}> | null;
|
||||
}
|
||||
|
||||
export interface PaymentMethodConfig {
|
||||
id: PaymentMethod;
|
||||
label: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface EmailTemplateConfig {
|
||||
subject: string;
|
||||
title: string;
|
||||
greeting: string;
|
||||
body: string;
|
||||
orderLabel: string;
|
||||
dateLabel: string;
|
||||
statusLabel: string;
|
||||
itemsLabel: string;
|
||||
subtotalLabel: string;
|
||||
shippingLabel: string;
|
||||
taxLabel: string;
|
||||
totalLabel: string;
|
||||
shippingAddressLabel: string;
|
||||
phoneLabel: string;
|
||||
phoneNotProvided: string;
|
||||
viewOrderButton: string;
|
||||
thankYou: string;
|
||||
companyName: string;
|
||||
footerText: string;
|
||||
}
|
||||
|
||||
export interface AdminEmailTemplateConfig {
|
||||
adminTitle: string;
|
||||
adminOrderLabel: string;
|
||||
customerLabel: string;
|
||||
nameLabel: string;
|
||||
emailLabel: string;
|
||||
dashboardButton: string;
|
||||
}
|
||||
|
||||
export interface EmailData {
|
||||
orderNumber: string;
|
||||
customerName: string;
|
||||
greeting: string;
|
||||
subject: string;
|
||||
orderDate: string;
|
||||
orderStatus: string;
|
||||
items: Array<{
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
}>;
|
||||
subtotal: string;
|
||||
shipping: string;
|
||||
tax: string;
|
||||
total: string;
|
||||
shippingAddress: string;
|
||||
phone: string;
|
||||
paymentMethod?: string;
|
||||
paymentLabel?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface AdminEmailData extends EmailData {
|
||||
customerEmail: string;
|
||||
adminTitle: string;
|
||||
adminOrderLabel: string;
|
||||
customerLabel: string;
|
||||
nameLabel: string;
|
||||
emailLabel: string;
|
||||
dashboardButton: string;
|
||||
}
|
||||
|
||||
export interface EmailResult {
|
||||
customerSubject: string;
|
||||
customerHtml: string;
|
||||
adminSubject: string;
|
||||
adminHtml: string;
|
||||
}
|
||||
|
||||
export interface EmailServiceConfig {
|
||||
fromEmail: string;
|
||||
fromName: string;
|
||||
adminEmails: string[];
|
||||
companyName: string;
|
||||
companyLogoUrl: string;
|
||||
dashboardUrl: string;
|
||||
storefrontUrl: string;
|
||||
trackingUrl?: string;
|
||||
}
|
||||
+91
-166
@@ -1,205 +1,130 @@
|
||||
import { Resend } from "resend";
|
||||
import { render } from "@react-email/components";
|
||||
import { OrderConfirmation, OrderShipped, OrderCancelled } from "@/emails";
|
||||
|
||||
let resendClient: Resend | null = null;
|
||||
|
||||
function getResendClient(): Resend {
|
||||
if (!resendClient) {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
throw new Error("RESEND_API_KEY environment variable is not set");
|
||||
}
|
||||
resendClient = new Resend(process.env.RESEND_API_KEY);
|
||||
}
|
||||
return resendClient;
|
||||
}
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
const FROM_EMAIL = process.env.FROM_EMAIL || "support@mail.manoonoils.com";
|
||||
const FROM_NAME = process.env.FROM_NAME || "ManoonOils";
|
||||
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || "me@hytham.me,tamara@hytham.me").split(",");
|
||||
const SITE_URL = process.env.SITE_URL || "https://dev.manoonoils.com";
|
||||
|
||||
export { FROM_EMAIL, FROM_NAME, ADMIN_EMAILS, SITE_URL };
|
||||
|
||||
function formatPrice(amount: number, currency: string): string {
|
||||
if (currency === "RSD") {
|
||||
return new Intl.NumberFormat("sr-RS", {
|
||||
style: "currency",
|
||||
currency: "RSD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function getCustomerName(order: any): string {
|
||||
if (order.user?.firstName) {
|
||||
return `${order.user.firstName} ${order.user.lastName || ""}`.trim();
|
||||
}
|
||||
if (order.billingAddress?.firstName) {
|
||||
return `${order.billingAddress.firstName} ${order.billingAddress.lastName || ""}`.trim();
|
||||
}
|
||||
return "Customer";
|
||||
}
|
||||
|
||||
function formatAddress(address: any): string {
|
||||
if (!address) return "";
|
||||
const parts = [
|
||||
address.streetAddress1,
|
||||
address.streetAddress2,
|
||||
address.city,
|
||||
address.postalCode,
|
||||
address.country?.code,
|
||||
].filter(Boolean);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
async function sendEmail({
|
||||
export async function sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
tags,
|
||||
idempotencyKey,
|
||||
}: {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
html: string;
|
||||
tags?: { name: string; value: string }[];
|
||||
idempotencyKey?: string;
|
||||
}) {
|
||||
const resend = getResendClient();
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: `${FROM_NAME} <${FROM_EMAIL}>`,
|
||||
to: Array.isArray(to) ? to : [to],
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
tags,
|
||||
...(idempotencyKey && { idempotencyKey }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
console.error("Resend error:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sendOrderConfirmationEmail(order: any) {
|
||||
const customerName = getCustomerName(order);
|
||||
const total = formatPrice(order.total?.gross?.amount || 0, order.total?.gross?.currency || "RSD");
|
||||
export function formatPrice(amount: number, currency: string) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
const html = `
|
||||
<h1>Order Confirmation #${order.number}</h1>
|
||||
<p>Hello ${customerName},</p>
|
||||
<p>Thank you for your order! Here are your order details:</p>
|
||||
<h2>Order #${order.number}</h2>
|
||||
<ul>
|
||||
${order.lines?.map((line: any) => `
|
||||
<li>${line.quantity}x ${line.productName} ${line.variantName ? `(${line.variantName})` : ""} - ${formatPrice(line.totalPrice?.gross?.amount || 0, line.totalPrice?.gross?.currency || "RSD")}</li>
|
||||
`).join("") || ""}
|
||||
</ul>
|
||||
<p><strong>Total: ${total}</strong></p>
|
||||
${order.shippingAddress ? `<h3>Shipping Address:</h3><p>${formatAddress(order.shippingAddress)}</p>` : ""}
|
||||
<p>You can view your order details <a href="${SITE_URL}/orders/${order.id}">here</a>.</p>
|
||||
<p>Thank you for shopping with us!</p>
|
||||
`;
|
||||
export async function sendOrderConfirmationEmail({
|
||||
to,
|
||||
orderData,
|
||||
isAdmin = false,
|
||||
}: {
|
||||
to: string | string[];
|
||||
orderData: {
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerEmail: string;
|
||||
customerName: string;
|
||||
items: Array<{ id: string; name: string; quantity: number; price: string }>;
|
||||
total: string;
|
||||
shippingAddress?: string;
|
||||
billingAddress?: string;
|
||||
phone?: string;
|
||||
};
|
||||
isAdmin?: boolean;
|
||||
}) {
|
||||
const html = await render(
|
||||
OrderConfirmation({
|
||||
...orderData,
|
||||
siteUrl: process.env.SITE_URL || "https://dev.manoonoils.com",
|
||||
dashboardUrl: process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com",
|
||||
isAdmin,
|
||||
})
|
||||
);
|
||||
|
||||
const subject = isAdmin
|
||||
? `🎉 New Order #${orderData.orderNumber}`
|
||||
: `Order Confirmation #${orderData.orderNumber}`;
|
||||
|
||||
return sendEmail({ to, subject, html });
|
||||
}
|
||||
|
||||
export async function sendOrderShippedEmail({
|
||||
to,
|
||||
orderData,
|
||||
}: {
|
||||
to: string | string[];
|
||||
orderData: {
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerName: string;
|
||||
items: Array<{ id: string; name: string; quantity: number; price: string }>;
|
||||
trackingNumber?: string;
|
||||
trackingUrl?: string;
|
||||
};
|
||||
}) {
|
||||
const html = await render(
|
||||
OrderShipped({
|
||||
...orderData,
|
||||
siteUrl: process.env.SITE_URL || "https://dev.manoonoils.com",
|
||||
})
|
||||
);
|
||||
|
||||
return sendEmail({
|
||||
to: order.userEmail || order.user?.email || "",
|
||||
subject: `Order Confirmation #${order.number}`,
|
||||
to,
|
||||
subject: `Your Order #${orderData.orderNumber} Has Shipped!`,
|
||||
html,
|
||||
tags: [{ name: "type", value: "order-confirmation" }],
|
||||
idempotencyKey: `order-confirmed/${order.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendOrderShippedEmail(order: any, trackingNumber?: string, trackingUrl?: string) {
|
||||
const customerName = getCustomerName(order);
|
||||
|
||||
const html = `
|
||||
<h1>Your Order #${order.number} Has Shipped!</h1>
|
||||
<p>Hello ${customerName},</p>
|
||||
<p>Great news! Your order has been shipped.</p>
|
||||
${trackingNumber ? `<p><strong>Tracking Number:</strong> ${trackingNumber}</p>` : ""}
|
||||
${trackingUrl ? `<p><a href="${trackingUrl}">Track your package</a></p>` : ""}
|
||||
<p>You can view your order details <a href="${SITE_URL}/orders/${order.id}">here</a>.</p>
|
||||
`;
|
||||
export async function sendOrderCancelledEmail({
|
||||
to,
|
||||
orderData,
|
||||
}: {
|
||||
to: string | string[];
|
||||
orderData: {
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerName: string;
|
||||
items: Array<{ id: string; name: string; quantity: number; price: string }>;
|
||||
total: string;
|
||||
reason?: string;
|
||||
};
|
||||
}) {
|
||||
const html = await render(
|
||||
OrderCancelled({
|
||||
...orderData,
|
||||
siteUrl: process.env.SITE_URL || "https://dev.manoonoils.com",
|
||||
})
|
||||
);
|
||||
|
||||
return sendEmail({
|
||||
to: order.userEmail || order.user?.email || "",
|
||||
subject: `Your Order #${order.number} Has Shipped!`,
|
||||
to,
|
||||
subject: `Your Order #${orderData.orderNumber} Has Been Cancelled`,
|
||||
html,
|
||||
tags: [{ name: "type", value: "order-shipped" }],
|
||||
idempotencyKey: `order-shipped/${order.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendOrderCancelledEmail(order: any, reason?: string) {
|
||||
const customerName = getCustomerName(order);
|
||||
|
||||
const html = `
|
||||
<h1>Order #${order.number} Cancelled</h1>
|
||||
<p>Hello ${customerName},</p>
|
||||
<p>Your order #${order.number} has been cancelled.</p>
|
||||
${reason ? `<p><strong>Reason:</strong> ${reason}</p>` : ""}
|
||||
<p>If you have any questions, please contact us.</p>
|
||||
`;
|
||||
|
||||
return sendEmail({
|
||||
to: order.userEmail || order.user?.email || "",
|
||||
subject: `Order #${order.number} Cancelled`,
|
||||
html,
|
||||
tags: [{ name: "type", value: "order-cancelled" }],
|
||||
idempotencyKey: `order-cancelled/${order.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendOrderPaidEmail(order: any) {
|
||||
const customerName = getCustomerName(order);
|
||||
|
||||
const html = `
|
||||
<h1>Payment Received - Order #${order.number}</h1>
|
||||
<p>Hello ${customerName},</p>
|
||||
<p>We have received your payment for order #${order.number}.</p>
|
||||
<p><strong>Total Paid:</strong> ${formatPrice(order.total?.gross?.amount || 0, order.total?.gross?.currency || "RSD")}</p>
|
||||
<p>Thank you for your purchase!</p>
|
||||
`;
|
||||
|
||||
return sendEmail({
|
||||
to: order.userEmail || order.user?.email || "",
|
||||
subject: `Payment Received - Order #${order.number}`,
|
||||
html,
|
||||
tags: [{ name: "type", value: "order-paid" }],
|
||||
idempotencyKey: `order-paid/${order.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendAdminNotification(order: any, eventType: string) {
|
||||
if (ADMIN_EMAILS.length === 0) {
|
||||
console.warn("No admin emails configured");
|
||||
return;
|
||||
}
|
||||
|
||||
const total = formatPrice(order.total?.gross?.amount || 0, order.total?.gross?.currency || "RSD");
|
||||
|
||||
const html = `
|
||||
<h1>[Admin] ${eventType} - Order #${order.number}</h1>
|
||||
<p><strong>Customer:</strong> ${order.userEmail || order.user?.email || "N/A"}</p>
|
||||
<p><strong>Total:</strong> ${total}</p>
|
||||
<p><strong>Items:</strong> ${order.lines?.length || 0}</p>
|
||||
<p><a href="${SITE_URL}/orders/${order.id}">View Order</a></p>
|
||||
`;
|
||||
|
||||
return sendEmail({
|
||||
to: ADMIN_EMAILS,
|
||||
subject: `[Admin] ${eventType} - Order #${order.number}`,
|
||||
html,
|
||||
tags: [{ name: "type", value: "admin-notification" }],
|
||||
idempotencyKey: `admin-${eventType}/${order.id}`,
|
||||
});
|
||||
}
|
||||
+75
-45
@@ -1,59 +1,89 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createManifestHandler } from "@saleor/app-sdk/handlers/next";
|
||||
import { AppExtension, AppManifest } from "@saleor/app-sdk/types";
|
||||
|
||||
import packageJson from "@/package.json";
|
||||
|
||||
/**
|
||||
* Custom manifest handler that bypasses SDK filtering
|
||||
* to include allowedSaleorApiUrls field
|
||||
*/
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const appBaseUrl = process.env.APP_API_BASE_URL || `https://${req.headers.host}`;
|
||||
const iframeBaseUrl = process.env.APP_IFRAME_BASE_URL || appBaseUrl;
|
||||
import { orderCreatedWebhook } from "./webhooks/order-created";
|
||||
import { orderFulfilledWebhook } from "./webhooks/order-fulfilled";
|
||||
import { orderCancelledWebhook } from "./webhooks/order-cancelled";
|
||||
import { orderFilterShippingMethodsWebhook } from "./webhooks/order-filter-shipping-methods";
|
||||
|
||||
const manifest = {
|
||||
/**
|
||||
* App SDK helps with the valid Saleor App Manifest creation. Read more:
|
||||
* https://github.com/saleor/saleor-app-sdk/blob/main/docs/api-handlers.md#manifest-handler-factory
|
||||
*/
|
||||
export default createManifestHandler({
|
||||
async manifestFactory({ appBaseUrl, request, schemaVersion }) {
|
||||
/**
|
||||
* Allow to overwrite default app base url, to enable Docker support.
|
||||
*
|
||||
* See docs: https://docs.saleor.io/docs/3.x/developer/extending/apps/local-app-development
|
||||
*/
|
||||
const iframeBaseUrl = process.env.APP_IFRAME_BASE_URL ?? appBaseUrl;
|
||||
const apiBaseURL = process.env.APP_API_BASE_URL ?? appBaseUrl;
|
||||
|
||||
const extensionsForSaleor3_22: AppExtension[] = [
|
||||
{
|
||||
url: apiBaseURL + "/api/server-widget",
|
||||
permissions: [],
|
||||
mount: "PRODUCT_DETAILS_WIDGETS",
|
||||
label: "Product Timestamps",
|
||||
target: "WIDGET",
|
||||
options: {
|
||||
widgetTarget: {
|
||||
method: "POST",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: iframeBaseUrl+"/client-widget",
|
||||
permissions: [],
|
||||
mount: "ORDER_DETAILS_WIDGETS",
|
||||
label: "Order widget example",
|
||||
target: "WIDGET",
|
||||
options: {
|
||||
widgetTarget: {
|
||||
method: "GET",
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const saleorMajor = schemaVersion && schemaVersion[0];
|
||||
const saleorMinor = schemaVersion && schemaVersion[1]
|
||||
|
||||
const is3_22 = saleorMajor === 3 && saleorMinor === 22;
|
||||
|
||||
const extensions = is3_22 ? extensionsForSaleor3_22 : [];
|
||||
|
||||
const manifest: AppManifest = {
|
||||
name: "Core Extensions",
|
||||
tokenTargetUrl: `${appBaseUrl}/api/register`,
|
||||
tokenTargetUrl: `${apiBaseURL}/api/register`,
|
||||
appUrl: iframeBaseUrl,
|
||||
permissions: ["MANAGE_ORDERS"],
|
||||
id: "saleor-core-extensions",
|
||||
permissions: [
|
||||
"MANAGE_ORDERS",
|
||||
],
|
||||
id: "saleor.core-extensions",
|
||||
version: packageJson.version,
|
||||
webhooks: [
|
||||
{
|
||||
name: "Order Created - N8N",
|
||||
targetUrl: `${appBaseUrl}/api/webhooks/order-created`,
|
||||
asyncEvents: ["ORDER_CREATED"],
|
||||
isActive: true,
|
||||
query: "subscription OrderCreatedSubscription { event { ... on OrderCreated { 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 } } } } } }"
|
||||
},
|
||||
{
|
||||
name: "Order Fulfilled - N8N",
|
||||
targetUrl: `${appBaseUrl}/api/webhooks/order-fulfilled`,
|
||||
asyncEvents: ["ORDER_FULFILLED"],
|
||||
isActive: true,
|
||||
query: "subscription OrderFulfilledSubscription { event { ... on OrderFulfilled { order { id number userEmail user { email firstName lastName } status fulfillments { id status created } } } } }"
|
||||
},
|
||||
{
|
||||
name: "Order Cancelled - N8N",
|
||||
targetUrl: `${appBaseUrl}/api/webhooks/order-cancelled`,
|
||||
asyncEvents: ["ORDER_CANCELLED"],
|
||||
isActive: true,
|
||||
query: "subscription OrderCancelledSubscription { event { ... on OrderCancelled { order { id number userEmail user { email firstName lastName } status } } } }"
|
||||
},
|
||||
orderCreatedWebhook.getWebhookManifest(apiBaseURL),
|
||||
orderFulfilledWebhook.getWebhookManifest(apiBaseURL),
|
||||
orderCancelledWebhook.getWebhookManifest(apiBaseURL),
|
||||
orderFilterShippingMethodsWebhook.getWebhookManifest(apiBaseURL),
|
||||
],
|
||||
extensions: [],
|
||||
author: "ManoonOils",
|
||||
/**
|
||||
* Optionally, extend Dashboard with custom UIs
|
||||
* https://docs.saleor.io/docs/3.x/developer/extending/apps/extending-dashboard-with-apps
|
||||
*/
|
||||
extensions: extensions,
|
||||
author: "Saleor Commerce",
|
||||
brand: {
|
||||
logo: {
|
||||
default: `${appBaseUrl}/logo.png`,
|
||||
default: `${apiBaseURL}/logo.png`,
|
||||
},
|
||||
},
|
||||
// Allow installation on these Saleor instances (accept both HTTP and HTTPS)
|
||||
allowedSaleorApiUrls: [
|
||||
"https://api.manoonoils.com/graphql/",
|
||||
"https://dashboard.manoonoils.com/graphql/",
|
||||
"http://api.manoonoils.com/graphql/",
|
||||
"http://dashboard.manoonoils.com/graphql/"
|
||||
]
|
||||
};
|
||||
|
||||
res.status(200).json(manifest);
|
||||
}
|
||||
return manifest;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
|
||||
// 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";
|
||||
|
||||
/**
|
||||
* Required endpoint, called by Saleor to install app.
|
||||
* It will exchange tokens with app, so saleorApp.apl will contain token
|
||||
*/
|
||||
export default createAppRegisterHandler({
|
||||
apl: saleorApp.apl,
|
||||
|
||||
allowedSaleorUrls: [
|
||||
"https://api.manoonoils.com/graphql/",
|
||||
"https://dashboard.manoonoils.com/graphql/",
|
||||
"http://api.manoonoils.com/graphql/",
|
||||
"http://dashboard.manoonoils.com/graphql/"
|
||||
],
|
||||
});
|
||||
@@ -1,6 +1,66 @@
|
||||
import { orderCancelledHandler } from "./order-notifications";
|
||||
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import {
|
||||
OrderCancelledSubscriptionDocument,
|
||||
OrderCancelledWebhookPayloadFragment,
|
||||
} from "@/generated/graphql";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import { getOrderCancelledEmails } from "@/lib/email-templates/order-cancelled";
|
||||
import { Resend } from "resend";
|
||||
|
||||
export default orderCancelledHandler;
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
const ADMIN_EMAILS = ["me@hytham.me", "tamara@hytham.me"];
|
||||
|
||||
export const orderCancelledWebhook = new SaleorAsyncWebhook<OrderCancelledWebhookPayloadFragment>({
|
||||
name: "Order Cancelled in Saleor",
|
||||
webhookPath: "api/webhooks/order-cancelled",
|
||||
event: "ORDER_CANCELLED",
|
||||
apl: saleorApp.apl,
|
||||
query: OrderCancelledSubscriptionDocument,
|
||||
});
|
||||
|
||||
export default orderCancelledWebhook.createHandler(async (req, res, ctx) => {
|
||||
const { payload } = ctx;
|
||||
const order = payload.order;
|
||||
|
||||
if (!order) {
|
||||
console.error("No order data in webhook payload");
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
console.log(`Order cancelled: ${order.number} for ${order.userEmail} (lang: ${order.languageCode})`);
|
||||
|
||||
try {
|
||||
// Get email templates
|
||||
const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderCancelledEmails(order);
|
||||
|
||||
// Send customer email
|
||||
if (order.userEmail) {
|
||||
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'}`);
|
||||
}
|
||||
|
||||
// Send admin emails
|
||||
for (const adminEmail of ADMIN_EMAILS) {
|
||||
const adminResult = await resend.emails.send({
|
||||
from: "Manoon Oils <support@mail.manoonoils.com>",
|
||||
to: adminEmail,
|
||||
subject: adminSubject,
|
||||
html: adminHtml,
|
||||
});
|
||||
console.log(`Admin cancelled email sent to ${adminEmail}: ${adminResult.data?.id || 'failed'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send cancelled emails:", error);
|
||||
}
|
||||
|
||||
return res.status(200).end();
|
||||
});
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { orderConfirmedHandler } from "./order-notifications";
|
||||
|
||||
export default orderConfirmedHandler;
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
OrderCreatedWebhookPayloadFragment,
|
||||
} from "@/generated/graphql";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import { getOrderCreatedEmails } from "@/lib/email-templates";
|
||||
import { Resend } from "resend";
|
||||
|
||||
// N8N webhook URL
|
||||
const N8N_WEBHOOK_URL = "https://n8n.nodecrew.me/webhook/saleor-order";
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
const ADMIN_EMAILS = ["me@hytham.me", "tamara@hytham.me"];
|
||||
|
||||
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedWebhookPayloadFragment>({
|
||||
name: "Order Created in Saleor",
|
||||
@@ -17,29 +20,43 @@ export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedWebhookPay
|
||||
});
|
||||
|
||||
export default orderCreatedWebhook.createHandler(async (req, res, ctx) => {
|
||||
const { payload, event, authData } = ctx;
|
||||
const { payload } = ctx;
|
||||
const order = payload.order;
|
||||
|
||||
console.log(`Order created: ${payload.order?.number} for ${payload.order?.userEmail}`);
|
||||
console.log(`Forwarding to N8N: ${N8N_WEBHOOK_URL}`);
|
||||
if (!order) {
|
||||
console.error("No order data in webhook payload");
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
console.log(`Order created: ${order.number} for ${order.userEmail} (lang: ${order.languageCode})`);
|
||||
|
||||
try {
|
||||
// Forward to N8N
|
||||
const response = await fetch(N8N_WEBHOOK_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-saleor-event": "order.created",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
// Get email templates
|
||||
const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderCreatedEmails(order);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`N8N returned ${response.status}: ${await response.text()}`);
|
||||
} else {
|
||||
console.log(`Successfully forwarded to N8N: ${response.status}`);
|
||||
// Send customer email
|
||||
if (order.userEmail) {
|
||||
const customerResult = await resend.emails.send({
|
||||
from: "Manoon Oils <support@mail.manoonoils.com>",
|
||||
to: order.userEmail,
|
||||
subject: customerSubject,
|
||||
html: customerHtml,
|
||||
});
|
||||
console.log(`Customer email sent: ${customerResult.data?.id || 'failed'}`);
|
||||
}
|
||||
|
||||
// Send admin emails
|
||||
for (const adminEmail of ADMIN_EMAILS) {
|
||||
const adminResult = await resend.emails.send({
|
||||
from: "Manoon Oils <support@mail.manoonoils.com>",
|
||||
to: adminEmail,
|
||||
subject: adminSubject,
|
||||
html: adminHtml,
|
||||
});
|
||||
console.log(`Admin email sent to ${adminEmail}: ${adminResult.data?.id || 'failed'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to forward to N8N:`, error);
|
||||
console.error("Failed to send emails:", error);
|
||||
}
|
||||
|
||||
return res.status(200).end();
|
||||
|
||||
@@ -1,6 +1,70 @@
|
||||
import { orderFulfilledHandler } from "./order-notifications";
|
||||
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import {
|
||||
OrderFulfilledSubscriptionDocument,
|
||||
OrderFulfilledWebhookPayloadFragment,
|
||||
} from "@/generated/graphql";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
import { getOrderShippedEmails } from "@/lib/email-templates";
|
||||
import { Resend } from "resend";
|
||||
|
||||
export default orderFulfilledHandler;
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
const ADMIN_EMAILS = ["me@hytham.me", "tamara@hytham.me"];
|
||||
|
||||
export const orderFulfilledWebhook = new SaleorAsyncWebhook<OrderFulfilledWebhookPayloadFragment>({
|
||||
name: "Order Fulfilled in Saleor",
|
||||
webhookPath: "api/webhooks/order-fulfilled",
|
||||
event: "ORDER_FULFILLED",
|
||||
apl: saleorApp.apl,
|
||||
query: OrderFulfilledSubscriptionDocument,
|
||||
});
|
||||
|
||||
export default orderFulfilledWebhook.createHandler(async (req, res, ctx) => {
|
||||
const { payload } = ctx;
|
||||
const order = payload.order;
|
||||
|
||||
if (!order) {
|
||||
console.error("No order data in webhook payload");
|
||||
return res.status(200).end();
|
||||
}
|
||||
|
||||
// Get the first fulfillment with tracking info
|
||||
const fulfillment = (order as any).fulfillments?.[0];
|
||||
const trackingNumber = fulfillment?.trackingNumber || "";
|
||||
|
||||
console.log(`Order fulfilled: ${order.number} for ${order.userEmail} (lang: ${order.languageCode}, tracking: ${trackingNumber})`);
|
||||
|
||||
try {
|
||||
// Get email templates
|
||||
const { customerSubject, customerHtml, adminSubject, adminHtml } = getOrderShippedEmails(order, fulfillment);
|
||||
|
||||
// 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'}`);
|
||||
}
|
||||
|
||||
// Send admin emails
|
||||
for (const adminEmail of ADMIN_EMAILS) {
|
||||
const adminResult = await resend.emails.send({
|
||||
from: "Manoon Oils <support@mail.manoonoils.com>",
|
||||
to: adminEmail,
|
||||
subject: adminSubject,
|
||||
html: adminHtml,
|
||||
});
|
||||
console.log(`Admin shipped email sent to ${adminEmail}: ${adminResult.data?.id || 'failed'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to send shipped emails:", error);
|
||||
}
|
||||
|
||||
return res.status(200).end();
|
||||
});
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { orderFullyPaidHandler } from "./order-notifications";
|
||||
|
||||
export default orderFullyPaidHandler;
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
@@ -1,108 +0,0 @@
|
||||
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
|
||||
import {
|
||||
OrderConfirmedSubscriptionDocument,
|
||||
OrderConfirmedWebhookPayloadFragment,
|
||||
OrderFulfilledSubscriptionDocument,
|
||||
OrderFulfilledWebhookPayloadFragment,
|
||||
OrderCancelledSubscriptionDocument,
|
||||
OrderCancelledWebhookPayloadFragment,
|
||||
} from "@/generated/graphql";
|
||||
import { saleorApp } from "@/saleor-app";
|
||||
|
||||
// N8N webhook URLs - these will forward Saleor events to N8N
|
||||
const N8N_BASE_URL = "https://n8n.nodecrew.me/webhook";
|
||||
|
||||
export const orderConfirmedWebhook = new SaleorAsyncWebhook<OrderConfirmedWebhookPayloadFragment>({
|
||||
name: "Order Confirmed - N8N",
|
||||
webhookPath: "api/webhooks/order-confirmed",
|
||||
event: "ORDER_CONFIRMED",
|
||||
apl: saleorApp.apl,
|
||||
query: OrderConfirmedSubscriptionDocument,
|
||||
});
|
||||
|
||||
export const orderFulfilledWebhook = new SaleorAsyncWebhook<OrderFulfilledWebhookPayloadFragment>({
|
||||
name: "Order Fulfilled - N8N",
|
||||
webhookPath: "api/webhooks/order-fulfilled",
|
||||
event: "ORDER_FULFILLED",
|
||||
apl: saleorApp.apl,
|
||||
query: OrderFulfilledSubscriptionDocument,
|
||||
});
|
||||
|
||||
export const orderCancelledWebhook = new SaleorAsyncWebhook<OrderCancelledWebhookPayloadFragment>({
|
||||
name: "Order Cancelled - N8N",
|
||||
webhookPath: "api/webhooks/order-cancelled",
|
||||
event: "ORDER_CANCELLED",
|
||||
apl: saleorApp.apl,
|
||||
query: OrderCancelledSubscriptionDocument,
|
||||
});
|
||||
|
||||
// Forward webhook payload to N8N
|
||||
async function forwardToN8N(eventType: string, payload: any) {
|
||||
const n8nUrl = `${N8N_BASE_URL}/saleor-${eventType}`;
|
||||
|
||||
console.log(`Forwarding ${eventType} to N8N: ${n8nUrl}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(n8nUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-saleor-event": eventType,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`N8N returned ${response.status}: ${await response.text()}`);
|
||||
throw new Error(`N8N returned ${response.status}`);
|
||||
}
|
||||
|
||||
console.log(`Successfully forwarded ${eventType} to N8N`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to forward ${eventType} to N8N:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export const orderConfirmedHandler = orderConfirmedWebhook.createHandler(async (req, res, ctx) => {
|
||||
const { payload } = ctx;
|
||||
|
||||
console.log(`Order confirmed received: ${payload.order?.id}`);
|
||||
|
||||
try {
|
||||
await forwardToN8N("order.created", payload);
|
||||
return res.status(200).end();
|
||||
} catch (error) {
|
||||
console.error("Failed to forward order confirmed:", error);
|
||||
return res.status(500).end();
|
||||
}
|
||||
});
|
||||
|
||||
export const orderFulfilledHandler = orderFulfilledWebhook.createHandler(async (req, res, ctx) => {
|
||||
const { payload } = ctx;
|
||||
|
||||
console.log(`Order fulfilled received: ${payload.order?.id}`);
|
||||
|
||||
try {
|
||||
await forwardToN8N("order.fulfilled", payload);
|
||||
return res.status(200).end();
|
||||
} catch (error) {
|
||||
console.error("Failed to forward order fulfilled:", error);
|
||||
return res.status(500).end();
|
||||
}
|
||||
});
|
||||
|
||||
export const orderCancelledHandler = orderCancelledWebhook.createHandler(async (req, res, ctx) => {
|
||||
const { payload } = ctx;
|
||||
|
||||
console.log(`Order cancelled received: ${payload.order?.id}`);
|
||||
|
||||
try {
|
||||
await forwardToN8N("order.cancelled", payload);
|
||||
return res.status(200).end();
|
||||
} catch (error) {
|
||||
console.error("Failed to forward order cancelled:", error);
|
||||
return res.status(500).end();
|
||||
}
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { Box, Text, Button, Chip, Divider } from "@saleor/macaw-ui";
|
||||
import { NextPage } from "next";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface WebhookStatus {
|
||||
name: string;
|
||||
event: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const DashboardPage: NextPage = () => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const webhooks: WebhookStatus[] = [
|
||||
{ name: "Order Created", event: "ORDER_CREATED", active: true },
|
||||
{ name: "Order Fulfilled", event: "ORDER_FULFILLED", active: true },
|
||||
{ name: "Order Cancelled", event: "ORDER_CANCELLED", active: true },
|
||||
];
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Box padding={8}>
|
||||
<Text>Loading...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box padding={8}>
|
||||
<Text size={11} as="h1" marginBottom={4}>
|
||||
Core Extensions Dashboard
|
||||
</Text>
|
||||
|
||||
<Text marginTop={2} marginBottom={8}>
|
||||
Email automation for ManoonOils
|
||||
</Text>
|
||||
|
||||
<Box marginBottom={8}>
|
||||
<Text size={8} as="h2" marginBottom={4}>
|
||||
Connection Status
|
||||
</Text>
|
||||
|
||||
{appBridgeState?.ready ? (
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Chip>Connected</Chip>
|
||||
<Text>Connected to Saleor Dashboard</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Chip>Standalone</Chip>
|
||||
<Text>Running outside Saleor Dashboard</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box marginY={8}>
|
||||
<Text size={8} as="h2" marginBottom={4}>
|
||||
Webhooks Configuration
|
||||
</Text>
|
||||
|
||||
<Text marginBottom={4}>
|
||||
Active webhooks forwarding to N8N
|
||||
</Text>
|
||||
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
{webhooks.map((webhook) => (
|
||||
<Box
|
||||
key={webhook.event}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
padding={2}
|
||||
>
|
||||
<Box>
|
||||
<Text>{webhook.name}</Text>
|
||||
<Text size={2}>{webhook.event}</Text>
|
||||
</Box>
|
||||
<Chip>{webhook.active ? "Active" : "Inactive"}</Chip>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box marginY={8}>
|
||||
<Text size={8} as="h2" marginBottom={4}>
|
||||
Email Configuration
|
||||
</Text>
|
||||
|
||||
<Box marginBottom={4}>
|
||||
<Text size={6} marginBottom={2}>Customer Emails</Text>
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Text>From:</Text>
|
||||
<Text>support@mail.manoonoils.com</Text>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Text>Provider:</Text>
|
||||
<Text>Resend</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text size={6} marginBottom={2}>Admin Notifications</Text>
|
||||
<Box display="flex" flexDirection="column" gap={2}>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Text>Recipients:</Text>
|
||||
<Text>me@hytham.me, tamara@hytham.me</Text>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between">
|
||||
<Text>Subject:</Text>
|
||||
<Text>New Order! #{'{orderNumber}'}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box marginTop={8} display="flex" gap={4}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => window.open("https://n8n.nodecrew.me", "_blank")}
|
||||
>
|
||||
Open N8N
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => window.open("https://resend.com/emails", "_blank")}
|
||||
>
|
||||
View Resend
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
+182
-72
@@ -1,96 +1,206 @@
|
||||
import { useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { Box, Text, Button, CircularProgress } from "@saleor/macaw-ui";
|
||||
import { actions, useAppBridge } from "@saleor/app-sdk/app-bridge";
|
||||
import { Box, Button, Input, Text } from "@saleor/macaw-ui";
|
||||
import { NextPage } from "next";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import { MouseEventHandler, useEffect, useState } from "react";
|
||||
|
||||
const AddToSaleorForm = () => (
|
||||
<Box
|
||||
as={"form"}
|
||||
display={"flex"}
|
||||
alignItems={"center"}
|
||||
gap={4}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const saleorUrl = new FormData(event.currentTarget as HTMLFormElement).get("saleor-url");
|
||||
const manifestUrl = new URL("/api/manifest", window.location.origin);
|
||||
const redirectUrl = new URL(
|
||||
`/dashboard/apps/install?manifestUrl=${manifestUrl}`,
|
||||
saleorUrl as string
|
||||
).href;
|
||||
|
||||
window.open(redirectUrl, "_blank");
|
||||
}}
|
||||
>
|
||||
<Input type="url" required label="Saleor URL" name="saleor-url" />
|
||||
<Button type="submit">Add to Saleor</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
/**
|
||||
* This is page publicly accessible from your app.
|
||||
* You should probably remove it.
|
||||
*/
|
||||
const IndexPage: NextPage = () => {
|
||||
const { appBridgeState } = useAppBridge();
|
||||
const router = useRouter();
|
||||
const { appBridgeState, appBridge } = useAppBridge();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// If inside Saleor Dashboard, redirect to dashboard
|
||||
const handleLinkClick: MouseEventHandler<HTMLAnchorElement> = (e) => {
|
||||
/**
|
||||
* In iframe, link can't be opened in new tab, so Dashboard must be a proxy
|
||||
*/
|
||||
if (appBridgeState?.ready) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
}, [appBridgeState?.ready, router]);
|
||||
e.preventDefault();
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" height="100vh">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
appBridge?.dispatch(
|
||||
actions.Redirect({
|
||||
newContext: true,
|
||||
to: e.currentTarget.href,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// If inside Saleor Dashboard, show loading while redirecting
|
||||
if (appBridgeState?.ready) {
|
||||
/**
|
||||
* Otherwise, assume app is accessed outside of Dashboard, so href attribute on <a> will work
|
||||
*/
|
||||
};
|
||||
|
||||
const isLocalHost = global.location.href.includes("localhost");
|
||||
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" height="100vh">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Standalone landing page
|
||||
return (
|
||||
<Box padding={8} maxWidth={800} margin="0 auto">
|
||||
<Text size={11} as="h1" marginBottom={4}>
|
||||
Core Extensions
|
||||
</Text>
|
||||
<Text size={6} color="textSecondary" marginBottom={8}>
|
||||
Email automation for ManoonOils
|
||||
<Box padding={8}>
|
||||
<Text size={11}>Welcome to Saleor App Template (Next.js) 🚀</Text>
|
||||
<Text as={"p"} marginY={4}>
|
||||
Saleor App Template is a minimalistic boilerplate that provides a working example of a
|
||||
Saleor app.
|
||||
</Text>
|
||||
{appBridgeState?.ready && mounted && (
|
||||
<Link href="/actions">
|
||||
<Button variant="secondary">See what your app can do →</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Box
|
||||
as="div"
|
||||
backgroundColor="surfaceNeutralPlain"
|
||||
padding={8}
|
||||
borderRadius={4}
|
||||
marginBottom={8}
|
||||
<Text as={"p"} marginTop={8}>
|
||||
Explore the App Template by visiting:
|
||||
</Text>
|
||||
<ul>
|
||||
<li>
|
||||
<code>/src/pages/api/manifest</code> - the{" "}
|
||||
<a
|
||||
href="https://docs.saleor.io/docs/3.x/developer/extending/apps/manifest"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text size={7} marginBottom={4}>
|
||||
Features
|
||||
App Manifest
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
<li>
|
||||
<code>/src/pages/api/webhooks/order-created</code> - an example <code>ORDER_CREATED</code>{" "}
|
||||
webhook handler.
|
||||
</li>
|
||||
<li>
|
||||
<code>/graphql</code> - the pre-defined GraphQL queries.
|
||||
</li>
|
||||
<li>
|
||||
<code>/generated/graphql.ts</code> - the code generated for those queries by{" "}
|
||||
<a target="_blank" rel="noreferrer" href="https://the-guild.dev/graphql/codegen">
|
||||
GraphQL Code Generator
|
||||
</a>
|
||||
.
|
||||
</li>
|
||||
</ul>
|
||||
<Text size={8} marginTop={8} as={"h2"}>
|
||||
Resources
|
||||
</Text>
|
||||
<Box as="ul" display="flex" flexDirection="column" gap={2}>
|
||||
<li>✉️ Automated order confirmation emails</li>
|
||||
<li>📦 Shipping notification emails</li>
|
||||
<li>❌ Order cancellation emails</li>
|
||||
<li>🔔 Admin notifications for all orders</li>
|
||||
<li>🎨 Styled HTML email templates</li>
|
||||
</Box>
|
||||
</Box>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
href="https://docs.saleor.io/docs/3.x/developer/extending/apps/key-concepts"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text color={"info1"}>Apps documentation </Text>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://docs.saleor.io/docs/3.x/developer/extending/apps/developing-with-tunnels"
|
||||
>
|
||||
<Text color={"info1"}>Tunneling the app</Text>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://github.com/saleor/app-examples"
|
||||
>
|
||||
<Text color={"info1"}>App Examples repository</Text>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<Box display="flex" flexDirection="column" gap={4}>
|
||||
<Text>
|
||||
This app connects Saleor to N8N for automated email workflows using Resend.
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href="https://github.com/saleor/saleor-app-sdk"
|
||||
>
|
||||
<Text color={"info1"}>Saleor App SDK</Text>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
href="https://github.com/saleor/saleor-cli"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text color={"info1"}>Saleor CLI</Text>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
href="https://github.com/saleor/apps"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text color={"info1"}>Saleor App Store - official apps by Saleor Team</Text>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
href="https://macaw-ui-next.vercel.app/?path=/docs/getting-started-installation--docs"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text color={"info1"}>Macaw UI - official Saleor UI library</Text>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
onClick={handleLinkClick}
|
||||
target="_blank"
|
||||
href="https://nextjs.org/docs"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Text color={"info1"}>Next.js documentation</Text>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{mounted && !isLocalHost && !appBridgeState?.ready && (
|
||||
<>
|
||||
<Text marginBottom={4} as={"p"}>
|
||||
Install this app in your Dashboard and get extra powers!
|
||||
</Text>
|
||||
|
||||
<Box display="flex" gap={4} marginTop={4}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
window.open("https://dashboard.manoonoils.com/apps", "_blank");
|
||||
}}
|
||||
>
|
||||
Open in Saleor Dashboard
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
router.push("/dashboard");
|
||||
}}
|
||||
>
|
||||
View Dashboard
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<AddToSaleorForm />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
+40
-14
@@ -1,26 +1,52 @@
|
||||
import { APL } from "@saleor/app-sdk/APL";
|
||||
import { APL, AuthData } from "@saleor/app-sdk/APL";
|
||||
import { SaleorApp } from "@saleor/app-sdk/saleor-app";
|
||||
import { FileAPL } from "@saleor/app-sdk/APL/file";
|
||||
|
||||
/**
|
||||
* By default auth data are stored in the `.auth-data.json` (FileAPL).
|
||||
* For multi-tenant applications and deployments please use UpstashAPL.
|
||||
*
|
||||
* To read more about storing auth data, read the
|
||||
* [APL documentation](https://github.com/saleor/saleor-app-sdk/blob/main/docs/apl.md)
|
||||
* APL wrapper that normalizes HTTP to HTTPS for auth data lookups
|
||||
*/
|
||||
class NormalizingAPL implements APL {
|
||||
private apl: FileAPL;
|
||||
|
||||
constructor(config: { fileName: string }) {
|
||||
this.apl = new FileAPL(config);
|
||||
}
|
||||
|
||||
private normalizeUrl(url: string): string {
|
||||
return url.replace(/^http:\/\//, "https://");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async set(authData: AuthData): Promise<void> {
|
||||
const normalizedUrl = this.normalizeUrl(authData.saleorApiUrl);
|
||||
console.log(`[NormalizingAPL] Storing auth for: ${authData.saleorApiUrl} -> ${normalizedUrl}`);
|
||||
return this.apl.set({
|
||||
...authData,
|
||||
saleorApiUrl: normalizedUrl,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(saleorApiUrl: string): Promise<void> {
|
||||
const normalizedUrl = this.normalizeUrl(saleorApiUrl);
|
||||
return this.apl.delete(normalizedUrl);
|
||||
}
|
||||
|
||||
async getAll(): Promise<AuthData[]> {
|
||||
return this.apl.getAll();
|
||||
}
|
||||
}
|
||||
|
||||
export let apl: APL;
|
||||
|
||||
switch (process.env.APL) {
|
||||
/**
|
||||
* Depending on env variables, chose what APL to use.
|
||||
* To reduce the footprint, import only these needed
|
||||
*
|
||||
* TODO: See docs
|
||||
*/
|
||||
default:
|
||||
apl = new FileAPL({
|
||||
fileName: process.env.AUTH_DATA_FILE_PATH || ".auth-data.json"
|
||||
apl = new NormalizingAPL({
|
||||
fileName: process.env.AUTH_DATA_FILE_PATH || "/tmp/.auth-data.json",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user