5 Commits

Author SHA1 Message Date
Unchained 513a8b7866 chore: add .dockerignore to exclude node_modules 2026-03-27 07:31:07 +02:00
Unchained 41fd8a5099 feat: add settings UI for webhook toggles
- Add settings store (src/lib/settings.ts) for persistent configuration
- Add settings API route for CRUD operations
- Add settings page with webhook toggles for order events
- Update webhook handlers to check settings before sending emails
- Settings include: webhook enable/disable, admin/customer notification toggles
- Email configuration: from name, from email, admin emails, store/dashboard URLs
2026-03-27 07:16:03 +02:00
Unchained 7b52ab585a refactor: make app portable for open source
- Remove hardcoded api.manoonoils.com fetch patch
- Make allowedSaleorUrls configurable via ALLOWED_SALEOR_URLS env var
- Make email logo/company configurable via EMAIL_LOGO_URL, EMAIL_COMPANY_NAME
- Add comprehensive README with deployment docs
- Keep internal networking preference via SALEOR_API_URL env var
2026-03-27 06:23:54 +02:00
Unchained debb1365b5 feat: prefer SALEOR_API_URL env var over stored auth URL
- NormalizingAPL now checks for SALEOR_API_URL env var
- If set, uses env var URL for API calls while keeping stored token
- Enables internal K8s networking without reinstallation
- Makes app portable across different network configurations

The app stores auth data during installation, but at runtime uses
SALEOR_API_URL for API calls. This allows deployment with internal
networking (e.g., http://saleor-api.saleor:8000) while keeping the
same token from installation.
2026-03-27 06:18:39 +02:00
Unchained 33fb9a8452 feat: complete saleor core extensions app with React email templates
- Order confirmation, shipped, and cancelled email templates
- Uses @react-email/components for professional HTML emails
- Sends admin and customer notifications
- Integrates with Resend for email delivery
- Webhook handlers for ORDER_CREATED, ORDER_FULFILLED, ORDER_CANCELLED
- Docker image optimized for production
- Persistent auth data storage via PVC
2026-03-27 05:21:56 +02:00
38 changed files with 13280 additions and 1611 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
.next
.git
*.log
.DS_Store
+8 -14
View File
@@ -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=
-18
View File
@@ -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)
+10 -17
View File
@@ -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"]
CMD ["node", "server.js"]
-97
View File
@@ -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.
+114 -86
View File
@@ -1,112 +1,140 @@
<div align="center">
<img width="150" alt="saleor-app-template" src="https://user-images.githubusercontent.com/4006792/215185065-4ef2eda4-ca71-48cc-b14b-c776e0b491b6.png">
</div>
# Saleor Core Extensions
<div align="center">
<h1>Saleor App Template</h1>
</div>
A Saleor app that sends email notifications for order events (created, fulfilled, cancelled) using React Email templates.
<div align="center">
<p>Bare-bones boilerplate for writing Saleor Apps with Next.js.</p>
</div>
## Features
<div align="center">
<a href="https://saleor.io/">Website</a>
<span> | </span>
<a href="https://docs.saleor.io/docs/3.x/">Docs</a>
<span> | </span>
<a href="https://githubbox.com/saleor/saleor-app-template">CodeSandbox</a>
</div>
- **Order Created** - Sends confirmation emails to customer and admin
- **Order Fulfilled** - Sends shipping notification with tracking
- **Order Cancelled** - Sends cancellation notification
- **Multi-language** - Supports EN, SR, DE, FR
- **React Email** - Professional HTML emails with responsive design
> [!TIP]
> Questions or issues? Check our [discord](https://discord.gg/H52JTZAtSH) channel for help.
## Installation
### What is Saleor App
### Option 1: Install via Manifest URL
Saleor App is the fastest way of extending Saleor with custom logic using [asynchronous](https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks) and [synchronous](https://docs.saleor.io/docs/3.x/developer/extending/apps/synchronous-webhooks/key-concepts) webhooks (and vast Saleor's API). In most cases, creating an App consists of two tasks:
In your Saleor Dashboard, go to Apps → Install App → Enter manifest URL:
- Writing webhook's code executing your custom logic.
- Developing configuration UI to be displayed in Saleor Dashboard via specialized view (designated in the App's manifest).
```
https://your-app-domain.com/api/manifest
```
### What's included?
### Option 2: Manual Installation
- 🚀 Communication between Saleor instance and Saleor App
- 📖 Manifest with webhooks using custom query
```bash
# Clone the repository
git clone https://github.com/your-org/saleor-core-extensions.git
cd saleor-core-extensions
### Why Next.js
# Install dependencies
pnpm install
You can use any preferred technology to create Saleor Apps, but Next.js is among the most efficient for two reasons. The first is the simplicity of maintaining your API endpoints/webhooks and your apps' configuration React front-end in a single, well-organized project. The second reason is the ease and quality of local development and deployment.
# Build Docker image
docker build -t ghcr.io/your-org/saleor-core-extensions:latest .
### Learn more about Apps
# Deploy to your K8s cluster
kubectl apply -f deployment.yaml
```
[Apps guide](https://docs.saleor.io/docs/3.x/developer/extending/apps/key-concepts)
## Configuration
### Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `SALEOR_API_URL` | Yes | - | Internal K8s URL for Saleor API (e.g., `http://saleor-api.saleor:8000/graphql/`) |
| `ALLOWED_SALEOR_URLS` | No | `http://localhost:3000` | Comma-separated list of allowed Saleor API URLs |
| `RESEND_API_KEY` | Yes | - | API key from [Resend.com](https://resend.com) |
| `FROM_EMAIL` | No | `support@mail.manoonoils.com` | Sender email address |
| `FROM_NAME` | No | `ManoonOils` | Sender name |
| `ADMIN_EMAILS` | Yes | - | Comma-separated admin emails for notifications |
| `SITE_URL` | No | `https://dev.manoonoils.com` | Public store URL |
| `DASHBOARD_URL` | No | `https://dashboard.manoonoils.com` | Saleor dashboard URL |
| `APP_IFRAME_BASE_URL` | Yes | - | Public URL where app is hosted |
| `APP_API_BASE_URL` | Yes | - | Same as APP_IFRAME_BASE_URL |
| `AUTH_DATA_FILE_PATH` | No | `/tmp/.auth-data.json` | Path for auth data storage |
| `EMAIL_LOGO_URL` | No | - | URL to company logo for emails |
| `EMAIL_COMPANY_NAME` | No | `Store` | Company name in emails |
| `EMAIL_FOOTER` | No | auto | Footer text in emails |
### Kubernetes Deployment
```yaml
env:
- name: SALEOR_API_URL
value: "http://saleor-api.saleor:8000/graphql/"
- name: APP_IFRAME_BASE_URL
value: "https://your-app.domain.com"
- name: APP_API_BASE_URL
value: "https://your-app.domain.com"
- name: RESEND_API_KEY
valueFrom:
secretKeyRef:
name: core-extensions-secrets
key: resend-api-key
- name: ADMIN_EMAILS
value: "admin@example.com"
```
## Architecture
```
┌─────────────────┐ Webhooks ┌──────────────────┐
│ Saleor Cloud │ ───────────────► │ Core Extensions │
│ │ │ App │
└─────────────────┘ └────────┬─────────┘
▲ │
│ ▼
│ ┌──────────────┐
│ │ Resend │
└────── GraphQL API ───────────│ (Email) │
└──────────────┘
```
### Internal Networking
The app uses `SALEOR_API_URL` to communicate with Saleor API internally, avoiding Cloudflare HTTP restrictions. The stored auth token is reused while the env var controls the API endpoint.
## Development
#### Running app locally in development containers
The easiest way of running app for local development is to use [development containers](https://containers.dev/).
If you have Visual Studio Code follow their [guide](https://code.visualstudio.com/docs/devcontainers/containers#_quick-start-open-an-existing-folder-in-a-container) on how to open existing folder in container.
Development container only creates container, you still need to start the server.
Development container will have port opened:
1. `3000` - were app dev server will listen to requests
### Requirements
Before you start, make sure you have installed:
- [Node.js 22](https://nodejs.org/en/)
- [pnpm 9](https://pnpm.io/)
1. Install the dependencies by running:
```
```bash
# Install dependencies
pnpm install
```
2. Start the local server with:
```
# Start development server
pnpm dev
# Generate GraphQL types
pnpm generate
# Build for production
pnpm build
```
3. Expose local environment using tunnel:
Use tunneling tools like [localtunnel](https://github.com/localtunnel/localtunnel) or [ngrok](https://ngrok.com/).
4. Install the application in your dashboard:
If you use Saleor Cloud or your local server is exposed, you can install your app by following this link:
## Project Structure
```
[YOUR_SALEOR_DASHBOARD_URL]/apps/install?manifestUrl=[YOUR_APP_TUNNEL_MANIFEST_URL]
src/
├── emails/
│ ├── BaseLayout.tsx # Email layout with logo/footer
│ ├── OrderConfirmation.tsx # Order confirmation email
│ ├── OrderShipped.tsx # Order shipped email
│ └── OrderCancelled.tsx # Order cancelled email
├── lib/
│ ├── resend.ts # Email sending logic
│ └── create-graphq-client.ts
├── pages/
│ ├── api/
│ │ ├── manifest.ts # App manifest
│ │ └── register.ts # App registration
│ └── webhooks/
│ ├── order-created.ts
│ ├── order-fulfilled.ts
│ └── order-cancelled.ts
└── saleor-app.ts # APL configuration
```
This template host manifest at `/api/manifest`
## License
You can also install application using GQL or command line. Follow the guide [how to install your app](https://docs.saleor.io/docs/3.x/developer/extending/apps/installing-apps#installation-using-graphql-api) to learn more.
### Generated schema and typings
This project uses a `generate` npm script command to:
- Generate GraphQL schema and typed functions from Saleor's GraphQL endpoint.
- Generate types for Saleor sync webhook responses from JSON schema
Commit the `generated` folder to your repo as they are necessary for queries and keeping track of the GraphQL / JSON schema changes.
To generate GraphQL types we are using [GraphQL Codegen](https://www.graphql-code-generator.com/). For generating types from JSON schema we use [json-schema-to-typescript](https://www.npmjs.com/package/json-schema-to-typescript).
### Storing registration data - APL
During the registration process, Saleor API passes the auth token to the app. With this token App can query Saleor API with privileged access (depending on requested permissions during the installation).
To store this data, app-template use a different [APL interfaces](https://docs.saleor.io/developer/extending/apps/developing-apps/app-sdk/apl).
The choice of the APL is made using the `APL` environment variable. If the value is not set, FileAPL is used. Available choices:
- `file`: no additional setup is required. Good choice for local development. It can't be used for multi tenant-apps or be deployed (not intended for production)
- `upstash`: use [Upstash](https://upstash.com/) Redis as storage method. Free account required. It can be used for development and production and supports multi-tenancy. Requires `UPSTASH_URL` and `UPSTASH_TOKEN` environment variables to be set
If you want to use your own database, you can implement your own APL. [Check the documentation to read more](https://docs.saleor.io/developer/extending/apps/developing-apps/app-sdk/apl).
MIT
+157 -8
View File
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
fragment OrderCancelledWebhookPayload on OrderCancelled {
order {
id
number
userEmail
user {
email
firstName
lastName
}
status
}
}
+76 -2
View File
@@ -1,12 +1,86 @@
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
}
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
fragment OrderFulfilledWebhookPayload on OrderFulfilled {
order {
id
number
userEmail
user {
email
firstName
lastName
}
status
fulfillments {
id
status
created
}
}
}
@@ -0,0 +1,5 @@
subscription OrderCancelledSubscription {
event {
...OrderCancelledWebhookPayload
}
}
@@ -0,0 +1,5 @@
subscription OrderFulfilledSubscription {
event {
...OrderFulfilledWebhookPayload
}
}
+4 -9
View File
@@ -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: {
+121 -675
View File
File diff suppressed because it is too large Load Diff
+18 -16
View File
@@ -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,18 @@
"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",
"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 +53,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",
+10570
View File
File diff suppressed because it is too large Load Diff
-39
View File
@@ -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
+110
View File
@@ -0,0 +1,110 @@
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: "",
company: "",
},
en: {
footer: "",
company: "",
},
de: {
footer: "",
company: "",
},
fr: {
footer: "",
company: "",
},
};
const COMPANY_NAME = process.env.EMAIL_COMPANY_NAME || "Store";
const LOGO_URL = process.env.EMAIL_LOGO_URL || "";
const DEFAULT_FOOTER = process.env.EMAIL_FOOTER || `${COMPANY_NAME} | ${process.env.SITE_URL || ""}`;
function getFooter(language: string): string {
const t = translations[language] || translations.en;
const footer = t.footer || DEFAULT_FOOTER;
return footer.replace("{company}", COMPANY_NAME).replace("{siteUrl}", process.env.SITE_URL || "");
}
export function BaseLayout({ children, previewText, language, siteUrl }: BaseLayoutProps) {
const footer = getFooter(language);
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
{LOGO_URL && (
<Section style={styles.logoSection}>
<Img
src={LOGO_URL}
width="150"
height="auto"
alt={COMPANY_NAME}
style={styles.logo}
/>
</Section>
)}
{children}
<Section style={styles.footer}>
<Text style={styles.footerText}>{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,
},
};
+237
View File
@@ -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",
},
};
+340
View File
@@ -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",
},
};
+193
View File
@@ -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",
},
};
+4
View File
@@ -0,0 +1,4 @@
export { BaseLayout } from "./BaseLayout";
export { OrderConfirmation } from "./OrderConfirmation";
export { OrderShipped } from "./OrderShipped";
export { OrderCancelled } from "./OrderCancelled";
+9 -6
View File
@@ -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 }) => ({
url,
exchanges: [],
});
export const createClient = (url: string, getAuth: () => Promise<{ token: string }>) =>
urqlCreateClient({
url,
exchanges: [
cacheExchange,
fetchExchange,
],
});
+92 -167
View File
@@ -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");
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 function formatPrice(amount: number, currency: string) {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
}
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}`,
});
}
+133
View File
@@ -0,0 +1,133 @@
import { promises as fs } from "fs";
import path from "path";
export interface WebhookSettings {
orderCreated: {
enabled: boolean;
sendAdminNotification: boolean;
sendCustomerNotification: boolean;
};
orderFulfilled: {
enabled: boolean;
sendAdminNotification: boolean;
sendCustomerNotification: boolean;
};
orderCancelled: {
enabled: boolean;
sendAdminNotification: boolean;
sendCustomerNotification: boolean;
};
}
export interface EmailSettings {
fromEmail: string;
fromName: string;
adminEmails: string[];
siteUrl: string;
dashboardUrl: string;
}
export interface AppSettings {
webhooks: WebhookSettings;
email: EmailSettings;
updatedAt: string;
}
const DEFAULT_SETTINGS: AppSettings = {
webhooks: {
orderCreated: {
enabled: true,
sendAdminNotification: true,
sendCustomerNotification: true,
},
orderFulfilled: {
enabled: true,
sendAdminNotification: true,
sendCustomerNotification: true,
},
orderCancelled: {
enabled: true,
sendAdminNotification: true,
sendCustomerNotification: true,
},
},
email: {
fromEmail: process.env.FROM_EMAIL || "support@mail.manoonoils.com",
fromName: process.env.FROM_NAME || "ManoonOils",
adminEmails: process.env.ADMIN_EMAILS?.split(",").map((e) => e.trim()).filter(Boolean) || [],
siteUrl: process.env.SITE_URL || "https://manoonoils.com",
dashboardUrl: process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com",
},
updatedAt: new Date().toISOString(),
};
class SettingsStore {
private filePath: string;
private settings: AppSettings | null = null;
constructor() {
this.filePath = process.env.SETTINGS_FILE_PATH || "/tmp/.app-settings.json";
}
private async ensureFileExists(): Promise<void> {
try {
await fs.access(this.filePath);
} catch {
const dir = path.dirname(this.filePath);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(this.filePath, JSON.stringify(DEFAULT_SETTINGS, null, 2));
}
}
async get(): Promise<AppSettings> {
if (this.settings) {
return this.settings;
}
try {
await this.ensureFileExists();
const data = await fs.readFile(this.filePath, "utf-8");
this.settings = JSON.parse(data) as AppSettings;
return this.settings!;
} catch (error) {
console.error("Error reading settings:", error);
this.settings = DEFAULT_SETTINGS;
return this.settings;
}
}
async set(newSettings: Partial<AppSettings>): Promise<AppSettings> {
const current = await this.get();
const updated: AppSettings = {
...current,
...newSettings,
updatedAt: new Date().toISOString(),
};
try {
await fs.writeFile(this.filePath, JSON.stringify(updated, null, 2));
this.settings = updated;
return updated;
} catch (error) {
console.error("Error writing settings:", error);
throw error;
}
}
async isWebhookEnabled(webhook: keyof WebhookSettings): Promise<boolean> {
const settings = await this.get();
return settings.webhooks[webhook].enabled;
}
async shouldSendAdminNotification(webhook: keyof WebhookSettings): Promise<boolean> {
const settings = await this.get();
return settings.webhooks[webhook].sendAdminNotification;
}
async shouldSendCustomerNotification(webhook: keyof WebhookSettings): Promise<boolean> {
const settings = await this.get();
return settings.webhooks[webhook].sendCustomerNotification;
}
}
export const settingsStore = new SettingsStore();
+83 -53
View File
@@ -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";
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";
/**
* Custom manifest handler that bypasses SDK filtering
* to include allowedSaleorApiUrls field
* 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 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;
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 manifest = {
name: "Core Extensions",
tokenTargetUrl: `${appBaseUrl}/api/register`,
appUrl: iframeBaseUrl,
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 } } } }"
},
],
extensions: [],
author: "ManoonOils",
brand: {
logo: {
default: `${appBaseUrl}/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/"
]
};
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",
},
},
},
]
res.status(200).json(manifest);
}
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: `${apiBaseURL}/api/register`,
appUrl: iframeBaseUrl,
permissions: [
"MANAGE_ORDERS",
],
id: "saleor.core-extensions",
version: packageJson.version,
webhooks: [
orderCreatedWebhook.getWebhookManifest(apiBaseURL),
orderFulfilledWebhook.getWebhookManifest(apiBaseURL),
orderCancelledWebhook.getWebhookManifest(apiBaseURL),
orderFilterShippingMethodsWebhook.getWebhookManifest(apiBaseURL),
],
/**
* 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: `${apiBaseURL}/logo.png`,
},
},
};
return manifest;
},
});
+6 -13
View File
@@ -1,18 +1,11 @@
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
*/
const allowedSaleorUrls = process.env.ALLOWED_SALEOR_URLS
? process.env.ALLOWED_SALEOR_URLS.split(",").map((url) => url.trim())
: ["http://localhost:3000", "https://*.saleor.cloud"];
export default createAppRegisterHandler({
apl: saleorApp.apl,
allowedSaleorUrls: [
"https://api.manoonoils.com/graphql/",
"https://dashboard.manoonoils.com/graphql/",
"http://api.manoonoils.com/graphql/",
"http://dashboard.manoonoils.com/graphql/"
],
});
allowedSaleorUrls,
});
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { settingsStore, AppSettings } from "@/lib/settings";
export async function GET() {
try {
const settings = await settingsStore.get();
return NextResponse.json(settings);
} catch (error) {
console.error("Error getting settings:", error);
return NextResponse.json({ error: "Failed to get settings" }, { status: 500 });
}
}
export async function PUT(request: NextRequest) {
try {
const body = await request.json() as Partial<AppSettings>;
const settings = await settingsStore.set(body);
return NextResponse.json(settings);
} catch (error) {
console.error("Error updating settings:", error);
return NextResponse.json({ error: "Failed to update settings" }, { status: 500 });
}
}
export async function PATCH(request: NextRequest) {
try {
const body = await request.json() as Partial<AppSettings>;
const settings = await settingsStore.set(body);
return NextResponse.json(settings);
} catch (error) {
console.error("Error patching settings:", error);
return NextResponse.json({ error: "Failed to patch settings" }, { status: 500 });
}
}
+88 -2
View File
@@ -1,6 +1,92 @@
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 { sendOrderCancelledEmail, formatPrice } from "@/lib/resend";
import { settingsStore } from "@/lib/settings";
export default orderCancelledHandler;
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, event, baseUrl, authData } = ctx;
const order = payload.order;
if (!order) {
console.error("No order data in webhook payload");
return res.status(200).end();
}
const webhookEnabled = await settingsStore.isWebhookEnabled("orderCancelled");
const sendAdmin = await settingsStore.shouldSendAdminNotification("orderCancelled");
const sendCustomer = await settingsStore.shouldSendCustomerNotification("orderCancelled");
console.log(`❌ Order ${order.number} cancelled for customer: ${order.userEmail}`);
console.log(`📋 Webhook settings - enabled: ${webhookEnabled}, admin: ${sendAdmin}, customer: ${sendCustomer}`);
if (!webhookEnabled) {
console.log("⏭️ Webhook disabled, skipping notifications");
return res.status(200).end();
}
const items = ((order as any).lines || []).map((line: any) => ({
id: line.id,
name: line.variant?.product?.name || "Unknown Product",
quantity: line.quantity,
price: formatPrice(line.totalPrice?.gross?.amount || 0, line.totalPrice?.gross?.currency || "USD"),
}));
const orderData = {
orderId: order.id,
orderNumber: order.number || "Unknown",
customerName: (order as any).shippingAddress?.firstName
? `${(order as any).shippingAddress.firstName} ${(order as any).shippingAddress.lastName || ""}`.trim()
: order.userEmail?.split("@")[0] || "Customer",
items,
total: formatPrice((order as any).total?.gross?.amount || 0, (order as any).total?.gross?.currency || "USD"),
};
if (sendAdmin) {
try {
const adminEmails = process.env.ADMIN_EMAILS?.split(",").map(e => e.trim()).filter(e => e) || [];
if (adminEmails.length > 0) {
await sendOrderCancelledEmail({
to: adminEmails,
orderData,
});
console.log(`✅ Admin notification sent for cancelled order ${order.number}`);
}
} catch (error) {
console.error("❌ Failed to send admin email:", error);
}
}
if (sendCustomer) {
try {
if (order.userEmail) {
await sendOrderCancelledEmail({
to: order.userEmail,
orderData,
});
console.log(`✅ Customer notification sent for cancelled order ${order.number}`);
}
} catch (error) {
console.error("❌ Failed to send customer email:", error);
}
} else {
console.log("⏭️ Customer notification disabled, skipping");
}
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,
},
};
+82 -23
View File
@@ -4,9 +4,8 @@ import {
OrderCreatedWebhookPayloadFragment,
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
// N8N webhook URL
const N8N_WEBHOOK_URL = "https://n8n.nodecrew.me/webhook/saleor-order";
import { sendOrderConfirmationEmail, formatPrice } from "@/lib/resend";
import { settingsStore } from "@/lib/settings";
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedWebhookPayloadFragment>({
name: "Order Created in Saleor",
@@ -17,29 +16,89 @@ export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedWebhookPay
});
export default orderCreatedWebhook.createHandler(async (req, res, ctx) => {
const { payload, event, authData } = ctx;
const { payload, event, baseUrl, authData } = ctx;
const order = payload.order;
if (!order) {
console.error("No order data in webhook payload");
return res.status(200).end();
}
console.log(`Order created: ${payload.order?.number} for ${payload.order?.userEmail}`);
console.log(`Forwarding to N8N: ${N8N_WEBHOOK_URL}`);
const webhookEnabled = await settingsStore.isWebhookEnabled("orderCreated");
const sendAdmin = await settingsStore.shouldSendAdminNotification("orderCreated");
const sendCustomer = await settingsStore.shouldSendCustomerNotification("orderCreated");
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),
});
console.log(`🎉 Order #${order.number} created for customer: ${order.userEmail} (${order.languageCode || "EN"})`);
console.log(`📋 Webhook settings - enabled: ${webhookEnabled}, admin: ${sendAdmin}, customer: ${sendCustomer}`);
if (!response.ok) {
console.error(`N8N returned ${response.status}: ${await response.text()}`);
} else {
console.log(`Successfully forwarded to N8N: ${response.status}`);
if (!webhookEnabled) {
console.log("⏭️ Webhook disabled, skipping notifications");
return res.status(200).end();
}
const items = ((order as any).lines || []).map((line: any) => ({
id: line.id,
name: line.variant?.product?.name || "Unknown Product",
quantity: line.quantity,
price: formatPrice(line.totalPrice?.gross?.amount || 0, line.totalPrice?.gross?.currency || "USD"),
}));
const orderData = {
orderId: order.id,
orderNumber: order.number || "Unknown",
customerEmail: order.userEmail || "",
customerName: (order as any).shippingAddress?.firstName
? `${(order as any).shippingAddress.firstName} ${(order as any).shippingAddress.lastName || ""}`.trim()
: order.userEmail?.split("@")[0] || "Customer",
items,
total: formatPrice((order as any).total?.gross?.amount || 0, (order as any).total?.gross?.currency || "USD"),
shippingAddress: (order as any).shippingAddress
? `${(order as any).shippingAddress.firstName || ""} ${(order as any).shippingAddress.lastName || ""}\n${(order as any).shippingAddress.streetAddress1 || ""}\n${(order as any).shippingAddress.postalCode || ""} ${(order as any).shippingAddress.city || ""}\n${(order as any).shippingAddress.country?.country || ""}${(order as any).shippingAddress.phone ? `\nPhone: ${(order as any).shippingAddress.phone}` : ""}`
: undefined,
billingAddress: (order as any).billingAddress
? `${(order as any).billingAddress.firstName || ""} ${(order as any).billingAddress.lastName || ""}\n${(order as any).billingAddress.streetAddress1 || ""}\n${(order as any).billingAddress.postalCode || ""} ${(order as any).billingAddress.city || ""}\n${(order as any).billingAddress.country?.country || ""}${(order as any).billingAddress.phone ? `\nPhone: ${(order as any).billingAddress.phone}` : ""}`
: undefined,
phone: (order as any).shippingAddress?.phone,
};
if (sendAdmin) {
try {
const adminEmails = process.env.ADMIN_EMAILS?.split(",").map(e => e.trim()).filter(e => e) || [];
if (adminEmails.length > 0) {
await sendOrderConfirmationEmail({
to: adminEmails,
orderData,
isAdmin: true,
});
console.log(`✅ Admin notification sent for order #${order.number} to: ${adminEmails.join(", ")}`);
} else {
console.log("⚠️ No admin emails configured, skipping admin notification");
}
} catch (error) {
console.error("❌ Failed to send admin email:", error);
}
} catch (error) {
console.error(`Failed to forward to N8N:`, error);
} else {
console.log("⏭️ Admin notification disabled, skipping");
}
if (sendCustomer) {
try {
if (order.userEmail) {
await sendOrderConfirmationEmail({
to: order.userEmail,
orderData,
isAdmin: false,
});
console.log(`✅ Customer confirmation sent for order #${order.number} to: ${order.userEmail}`);
} else {
console.log("⚠️ No customer email found, skipping customer notification");
}
} catch (error) {
console.error("❌ Failed to send customer email:", error);
}
} else {
console.log("⏭️ Customer notification disabled, skipping");
}
return res.status(200).end();
@@ -49,4 +108,4 @@ export const config = {
api: {
bodyParser: false,
},
};
};
+87 -2
View File
@@ -1,6 +1,91 @@
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 { sendOrderShippedEmail, formatPrice } from "@/lib/resend";
import { settingsStore } from "@/lib/settings";
export default orderFulfilledHandler;
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, event, baseUrl, authData } = ctx;
const order = payload.order;
if (!order) {
console.error("No order data in webhook payload");
return res.status(200).end();
}
const webhookEnabled = await settingsStore.isWebhookEnabled("orderFulfilled");
const sendAdmin = await settingsStore.shouldSendAdminNotification("orderFulfilled");
const sendCustomer = await settingsStore.shouldSendCustomerNotification("orderFulfilled");
console.log(`📦 Order ${order.number} fulfilled for customer: ${order.userEmail}`);
console.log(`📋 Webhook settings - enabled: ${webhookEnabled}, admin: ${sendAdmin}, customer: ${sendCustomer}`);
if (!webhookEnabled) {
console.log("⏭️ Webhook disabled, skipping notifications");
return res.status(200).end();
}
const items = ((order as any).lines || []).map((line: any) => ({
id: line.id,
name: line.variant?.product?.name || "Unknown Product",
quantity: line.quantity,
price: formatPrice(line.totalPrice?.gross?.amount || 0, line.totalPrice?.gross?.currency || "USD"),
}));
const orderData = {
orderId: order.id,
orderNumber: order.number || "Unknown",
customerName: (order as any).shippingAddress?.firstName
? `${(order as any).shippingAddress.firstName} ${(order as any).shippingAddress.lastName || ""}`.trim()
: order.userEmail?.split("@")[0] || "Customer",
items,
};
if (sendAdmin) {
try {
const adminEmails = process.env.ADMIN_EMAILS?.split(",").map(e => e.trim()).filter(e => e) || [];
if (adminEmails.length > 0) {
await sendOrderShippedEmail({
to: adminEmails,
orderData,
});
console.log(`✅ Admin notification sent for fulfilled order ${order.number}`);
}
} catch (error) {
console.error("❌ Failed to send admin email:", error);
}
}
if (sendCustomer) {
try {
if (order.userEmail) {
await sendOrderShippedEmail({
to: order.userEmail,
orderData,
});
console.log(`✅ Customer notification sent for fulfilled order ${order.number}`);
}
} catch (error) {
console.error("❌ Failed to send customer email:", error);
}
} else {
console.log("⏭️ Customer notification disabled, skipping");
}
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();
}
});
-149
View File
@@ -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;
+184 -74
View File
@@ -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");
e.preventDefault();
appBridge?.dispatch(
actions.Redirect({
newContext: true,
to: e.currentTarget.href,
})
);
}
}, [appBridgeState?.ready, router]);
if (!mounted) {
return (
<Box display="flex" justifyContent="center" alignItems="center" height="100vh">
<CircularProgress />
</Box>
);
}
/**
* Otherwise, assume app is accessed outside of Dashboard, so href attribute on <a> will work
*/
};
// If inside Saleor Dashboard, show loading while redirecting
if (appBridgeState?.ready) {
return (
<Box display="flex" justifyContent="center" alignItems="center" height="100vh">
<CircularProgress />
</Box>
);
}
const isLocalHost = global.location.href.includes("localhost");
// Standalone landing page
return (
<Box padding={8} maxWidth={800} margin="0 auto">
<Text size={11} as="h1" marginBottom={4}>
Core Extensions
<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>
<Text size={6} color="textSecondary" marginBottom={8}>
Email automation for ManoonOils
{appBridgeState?.ready && mounted && (
<Link href="/actions">
<Button variant="secondary">See what your app can do </Button>
</Link>
)}
<Text as={"p"} marginTop={8}>
Explore the App Template by visiting:
</Text>
<Box
as="div"
backgroundColor="surfaceNeutralPlain"
padding={8}
borderRadius={4}
marginBottom={8}
>
<Text size={7} marginBottom={4}>
Features
</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>
<Box display="flex" flexDirection="column" gap={4}>
<Text>
This app connects Saleor to N8N for automated email workflows using Resend.
</Text>
<Box display="flex" gap={4} marginTop={4}>
<Button
variant="primary"
onClick={() => {
window.open("https://dashboard.manoonoils.com/apps", "_blank");
}}
<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"
>
Open in Saleor Dashboard
</Button>
<Button
variant="secondary"
onClick={() => {
router.push("/dashboard");
}}
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>
<ul>
<li>
<a
onClick={handleLinkClick}
target="_blank"
href="https://docs.saleor.io/docs/3.x/developer/extending/apps/key-concepts"
rel="noreferrer"
>
View Dashboard
</Button>
</Box>
</Box>
<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>
<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>
<AddToSaleorForm />
</>
)}
</Box>
);
};
+410
View File
@@ -0,0 +1,410 @@
import { Box, Button, Input, Text } from "@saleor/macaw-ui";
import { NextPage } from "next";
import { useCallback, useEffect, useState } from "react";
interface WebhookToggle {
enabled: boolean;
sendAdminNotification: boolean;
sendCustomerNotification: boolean;
}
interface WebhookSettings {
orderCreated: WebhookToggle;
orderFulfilled: WebhookToggle;
orderCancelled: WebhookToggle;
}
interface EmailSettings {
fromEmail: string;
fromName: string;
adminEmails: string;
siteUrl: string;
dashboardUrl: string;
}
interface Settings {
webhooks: WebhookSettings;
email: EmailSettings;
updatedAt: string;
}
const defaultSettings: Settings = {
webhooks: {
orderCreated: { enabled: true, sendAdminNotification: true, sendCustomerNotification: true },
orderFulfilled: { enabled: true, sendAdminNotification: true, sendCustomerNotification: true },
orderCancelled: { enabled: true, sendAdminNotification: true, sendCustomerNotification: true },
},
email: {
fromEmail: "",
fromName: "",
adminEmails: "",
siteUrl: "",
dashboardUrl: "",
},
updatedAt: "",
};
const webhookLabels: Record<keyof WebhookSettings, { title: string; description: string }> = {
orderCreated: {
title: "Order Created",
description: "Send email notifications when a new order is placed",
},
orderFulfilled: {
title: "Order Fulfilled",
description: "Send email notifications when an order is shipped",
},
orderCancelled: {
title: "Order Cancelled",
description: "Send email notifications when an order is cancelled",
},
};
const Toggle: React.FC<{
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
}> = ({ checked, onChange, disabled }) => (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
style={{
position: "relative",
width: "44px",
height: "24px",
borderRadius: "12px",
backgroundColor: checked ? "#22c55e" : "#d1d5db",
border: "none",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
transition: "background-color 0.2s",
padding: 0,
}}
>
<span
style={{
position: "absolute",
top: "2px",
left: checked ? "22px" : "2px",
width: "20px",
height: "20px",
borderRadius: "50%",
backgroundColor: "white",
transition: "left 0.2s",
boxShadow: "0 1px 3px rgba(0,0,0,0.2)",
}}
/>
</button>
);
const Card: React.FC<{
children: React.ReactNode;
style?: React.CSSProperties;
}> = ({ children, style }) => (
<div
style={{
backgroundColor: "#ffffff",
borderRadius: "12px",
border: "1px solid #e5e7eb",
padding: "24px",
...style,
}}
>
{children}
</div>
);
const SettingsPage: NextPage = () => {
const [settings, setSettings] = useState<Settings>(defaultSettings);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
const bgColor = "#f9fafb";
const textColor = "#111827";
const subtextColor = "#6b7280";
const borderColor = "#e5e7eb";
useEffect(() => {
fetch("/api/settings")
.then((res) => res.json())
.then((data: Settings) => {
setSettings({
...data,
email: {
...data.email,
adminEmails: Array.isArray(data.email.adminEmails)
? data.email.adminEmails.join(", ")
: data.email.adminEmails || "",
},
});
setLoading(false);
})
.catch((err) => {
console.error("Failed to load settings:", err);
setError("Failed to load settings");
setLoading(false);
});
}, []);
const handleWebhookChange = useCallback(
(webhook: keyof WebhookSettings, field: keyof WebhookToggle) => {
setSettings((prev) => ({
...prev,
webhooks: {
...prev.webhooks,
[webhook]: {
...prev.webhooks[webhook],
[field]: !prev.webhooks[webhook][field],
},
},
}));
setSaved(false);
},
[]
);
const handleEmailChange = useCallback((field: keyof EmailSettings, value: string) => {
setSettings((prev) => ({
...prev,
email: {
...prev.email,
[field]: value,
},
}));
setSaved(false);
}, []);
const handleSave = useCallback(async () => {
setSaving(true);
setError(null);
setSaved(false);
try {
const toSave = {
...settings,
email: {
...settings.email,
adminEmails: settings.email.adminEmails
.split(",")
.map((e) => e.trim())
.filter(Boolean),
},
};
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toSave),
});
if (!res.ok) {
throw new Error("Failed to save settings");
}
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch (err) {
console.error("Failed to save settings:", err);
setError("Failed to save settings. Please try again.");
} finally {
setSaving(false);
}
}, [settings]);
if (loading) {
return (
<Box padding={8} style={{ backgroundColor: bgColor, minHeight: "100vh" }}>
<Text size={5} style={{ color: textColor, fontWeight: "bold" }}>
Loading...
</Text>
</Box>
);
}
return (
<Box padding={8} style={{ backgroundColor: bgColor, minHeight: "100vh" }}>
<Box marginBottom={8}>
<Text size={7} style={{ color: textColor, fontWeight: "bold" }}>
Email Notifications
</Text>
<Text style={{ color: subtextColor, marginTop: "8px" }}>
Configure when to send email notifications for order events
</Text>
</Box>
{error && (
<Box
padding={4}
marginBottom={6}
style={{
backgroundColor: "#fee2e2",
borderRadius: "8px",
border: "1px solid #ef4444",
}}
>
<Text style={{ color: "#991b1b" }}>{error}</Text>
</Box>
)}
<Box marginBottom={6}>
<Text size={5} style={{ color: textColor, fontWeight: "bold", marginBottom: "16px" }}>
Webhooks
</Text>
{(Object.keys(webhookLabels) as Array<keyof WebhookSettings>).map((webhook) => {
const { title, description } = webhookLabels[webhook];
const webhookSettings = settings.webhooks[webhook];
return (
<Card key={webhook} style={{ marginBottom: "16px" }}>
<Box display="flex" alignItems="flex-start" justifyContent="space-between">
<Box style={{ flex: 1 }}>
<Text style={{ color: textColor, fontWeight: "bold" }}>
{title}
</Text>
<Text size={3} style={{ color: subtextColor, marginTop: "4px" }}>
{description}
</Text>
</Box>
<Toggle
checked={webhookSettings.enabled}
onChange={() => handleWebhookChange(webhook, "enabled")}
/>
</Box>
{webhookSettings.enabled && (
<Box
marginTop={4}
paddingTop={4}
style={{ borderTop: `1px solid ${borderColor}` }}
>
<Box
display="flex"
alignItems="center"
justifyContent="space-between"
marginBottom={3}
>
<Text size={3} style={{ color: subtextColor }}>
Send to admin emails
</Text>
<Toggle
checked={webhookSettings.sendAdminNotification}
onChange={() => handleWebhookChange(webhook, "sendAdminNotification")}
/>
</Box>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Text size={3} style={{ color: subtextColor }}>
Send to customer
</Text>
<Toggle
checked={webhookSettings.sendCustomerNotification}
onChange={() => handleWebhookChange(webhook, "sendCustomerNotification")}
/>
</Box>
</Box>
)}
</Card>
);
})}
</Box>
<Box marginBottom={6}>
<Text size={5} style={{ color: textColor, fontWeight: "bold", marginBottom: "16px" }}>
Email Configuration
</Text>
<Card>
<Box marginBottom={4}>
<Text size={3} style={{ color: textColor, fontWeight: 500, marginBottom: "8px" }}>
From Name
</Text>
<Input
value={settings.email.fromName}
onChange={(e) => handleEmailChange("fromName", e.target.value)}
placeholder="ManoonOils"
style={{ width: "100%" }}
/>
</Box>
<Box marginBottom={4}>
<Text size={3} style={{ color: textColor, fontWeight: 500, marginBottom: "8px" }}>
From Email
</Text>
<Input
type="email"
value={settings.email.fromEmail}
onChange={(e) => handleEmailChange("fromEmail", e.target.value)}
placeholder="support@mail.manoonoils.com"
style={{ width: "100%" }}
/>
</Box>
<Box marginBottom={4}>
<Text size={3} style={{ color: textColor, fontWeight: 500, marginBottom: "8px" }}>
Admin Emails (comma separated)
</Text>
<Input
value={settings.email.adminEmails}
onChange={(e) => handleEmailChange("adminEmails", e.target.value)}
placeholder="admin@example.com, manager@example.com"
style={{ width: "100%" }}
/>
</Box>
<Box marginBottom={4}>
<Text size={3} style={{ color: textColor, fontWeight: 500, marginBottom: "8px" }}>
Store URL
</Text>
<Input
type="url"
value={settings.email.siteUrl}
onChange={(e) => handleEmailChange("siteUrl", e.target.value)}
placeholder="https://manoonoils.com"
style={{ width: "100%" }}
/>
</Box>
<Box>
<Text size={3} style={{ color: textColor, fontWeight: 500, marginBottom: "8px" }}>
Dashboard URL
</Text>
<Input
type="url"
value={settings.email.dashboardUrl}
onChange={(e) => handleEmailChange("dashboardUrl", e.target.value)}
placeholder="https://dashboard.manoonoils.com"
style={{ width: "100%" }}
/>
</Box>
</Card>
</Box>
<Box display="flex" alignItems="center" gap={4}>
<Button
variant="primary"
onClick={handleSave}
disabled={saving}
>
{saving ? "Saving..." : "Save Settings"}
</Button>
{saved && (
<Text size={3} style={{ color: "#22c55e" }}>
Settings saved successfully!
</Text>
)}
</Box>
{settings.updatedAt && (
<Text size={3} style={{ color: subtextColor, marginTop: "16px" }}>
Last updated: {new Date(settings.updatedAt).toLocaleString()}
</Text>
)}
</Box>
);
};
export default SettingsPage;
+64 -15
View File
@@ -1,29 +1,78 @@
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:
* 1. Normalizes HTTP to HTTPS for auth data lookups (for Cloudflare compatibility)
* 2. Prefers SALEOR_API_URL env var over stored auth URL (for internal networking)
*/
class NormalizingAPL implements APL {
private apl: FileAPL;
constructor(config: { fileName: string }) {
this.apl = new FileAPL(config);
}
private normalizeUrl(url: string): string {
return url.replace(/^http:\/\//, "https://");
}
/**
* Get auth data and optionally override saleorApiUrl with env var
*/
async get(saleorApiUrl: string): Promise<AuthData | undefined> {
const normalizedUrl = this.normalizeUrl(saleorApiUrl);
console.log(`[NormalizingAPL] Looking up auth for: ${saleorApiUrl} -> ${normalizedUrl}`);
const authData = await this.apl.get(normalizedUrl);
if (!authData) {
return undefined;
}
// If SALEOR_API_URL is set, prefer it over the stored URL
// This enables internal networking (e.g., http://saleor-api.saleor:8000)
// while still using the stored token for authentication
if (process.env.SALEOR_API_URL) {
console.log(`[NormalizingAPL] Using SALEOR_API_URL: ${process.env.SALEOR_API_URL} (env var overrides stored: ${authData.saleorApiUrl})`);
return {
...authData,
saleorApiUrl: process.env.SALEOR_API_URL,
};
}
return authData;
}
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",
});
}
export const saleorApp = new SaleorApp({
apl,
});
});