4 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
12 changed files with 1583 additions and 197 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
.next
.git
*.log
.DS_Store
+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
+699 -27
View File
File diff suppressed because it is too large Load Diff
+31 -19
View File
@@ -21,25 +21,35 @@ interface BaseLayoutProps {
const translations: Record<string, { footer: string; company: string }> = {
sr: {
footer: "ManoonOils - Prirodna kozmetika | www.manoonoils.com",
company: "ManoonOils",
footer: "",
company: "",
},
en: {
footer: "ManoonOils - Natural Cosmetics | www.manoonoils.com",
company: "ManoonOils",
footer: "",
company: "",
},
de: {
footer: "ManoonOils - Natürliche Kosmetik | www.manoonoils.com",
company: "ManoonOils",
footer: "",
company: "",
},
fr: {
footer: "ManoonOils - Cosmétiques Naturels | www.manoonoils.com",
company: "ManoonOils",
footer: "",
company: "",
},
};
export function BaseLayout({ children, previewText, language, siteUrl }: BaseLayoutProps) {
const COMPANY_NAME = process.env.EMAIL_COMPANY_NAME || "Store";
const LOGO_URL = process.env.EMAIL_LOGO_URL || "";
const DEFAULT_FOOTER = process.env.EMAIL_FOOTER || `${COMPANY_NAME} | ${process.env.SITE_URL || ""}`;
function getFooter(language: string): string {
const t = translations[language] || translations.en;
const footer = t.footer || DEFAULT_FOOTER;
return footer.replace("{company}", COMPANY_NAME).replace("{siteUrl}", process.env.SITE_URL || "");
}
export function BaseLayout({ children, previewText, language, siteUrl }: BaseLayoutProps) {
const footer = getFooter(language);
return (
<Html>
@@ -47,18 +57,20 @@ export function BaseLayout({ children, previewText, language, siteUrl }: BaseLay
<Preview>{previewText}</Preview>
<Body style={styles.body}>
<Container style={styles.container}>
<Section style={styles.logoSection}>
<Img
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
width="150"
height="auto"
alt="ManoonOils"
style={styles.logo}
/>
</Section>
{LOGO_URL && (
<Section style={styles.logoSection}>
<Img
src={LOGO_URL}
width="150"
height="auto"
alt={COMPANY_NAME}
style={styles.logo}
/>
</Section>
)}
{children}
<Section style={styles.footer}>
<Text style={styles.footerText}>{t.footer}</Text>
<Text style={styles.footerText}>{footer}</Text>
</Section>
</Container>
</Body>
+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();
+5 -15
View File
@@ -1,21 +1,11 @@
// Patch fetch to force HTTPS for api.manoonoils.com
const originalFetch = global.fetch;
global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
let url = input.toString();
if (url.startsWith('http://api.manoonoils.com/')) {
url = url.replace('http://', 'https://');
input = url;
}
return originalFetch(input, init);
};
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "@/saleor-app";
const allowedSaleorUrls = process.env.ALLOWED_SALEOR_URLS
? process.env.ALLOWED_SALEOR_URLS.split(",").map((url) => url.trim())
: ["http://localhost:3000", "https://*.saleor.cloud"];
export default createAppRegisterHandler({
apl: saleorApp.apl,
allowedSaleorUrls: [
"https://api.manoonoils.com/graphql/",
"http://api.manoonoils.com/graphql/",
],
allowedSaleorUrls,
});
+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 });
}
}
+41 -10
View File
@@ -5,6 +5,7 @@ import {
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
import { sendOrderCancelledEmail, formatPrice } from "@/lib/resend";
import { settingsStore } from "@/lib/settings";
export const orderCancelledWebhook = new SaleorAsyncWebhook<OrderCancelledWebhookPayloadFragment>({
name: "Order Cancelled in Saleor",
@@ -23,7 +24,17 @@ export default orderCancelledWebhook.createHandler(async (req, res, ctx) => {
return res.status(200).end();
}
console.log(`Order ${order.number} cancelled for customer: ${order.userEmail}`);
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,
@@ -42,16 +53,36 @@ export default orderCancelledWebhook.createHandler(async (req, res, ctx) => {
total: formatPrice((order as any).total?.gross?.amount || 0, (order as any).total?.gross?.currency || "USD"),
};
try {
if (order.userEmail) {
await sendOrderCancelledEmail({
to: order.userEmail,
orderData,
});
console.log(`Customer notification sent for cancelled order ${order.number}`);
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);
}
} catch (error) {
console.error("Failed to send 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();
+45 -28
View File
@@ -5,6 +5,7 @@ import {
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
import { sendOrderConfirmationEmail, formatPrice } from "@/lib/resend";
import { settingsStore } from "@/lib/settings";
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedWebhookPayloadFragment>({
name: "Order Created in Saleor",
@@ -23,7 +24,17 @@ export default orderCreatedWebhook.createHandler(async (req, res, ctx) => {
return res.status(200).end();
}
const webhookEnabled = await settingsStore.isWebhookEnabled("orderCreated");
const sendAdmin = await settingsStore.shouldSendAdminNotification("orderCreated");
const sendCustomer = await settingsStore.shouldSendCustomerNotification("orderCreated");
console.log(`🎉 Order #${order.number} created for customer: ${order.userEmail} (${order.languageCode || "EN"})`);
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,
@@ -50,38 +61,44 @@ export default orderCreatedWebhook.createHandler(async (req, res, ctx) => {
phone: (order as any).shippingAddress?.phone,
};
// Send admin notification
try {
const adminEmails = process.env.ADMIN_EMAILS?.split(",").map(e => e.trim()).filter(e => e) || [];
if (adminEmails.length > 0) {
await sendOrderConfirmationEmail({
to: adminEmails,
orderData,
isAdmin: true,
});
console.log(`✅ Admin notification sent for order #${order.number} to: ${adminEmails.join(", ")}`);
} else {
console.log("⚠️ No admin emails configured, skipping admin notification");
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 send admin email:", error);
} else {
console.log("⏭️ Admin notification disabled, skipping");
}
// Send customer confirmation
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");
if (sendCustomer) {
try {
if (order.userEmail) {
await sendOrderConfirmationEmail({
to: order.userEmail,
orderData,
isAdmin: false,
});
console.log(`✅ Customer confirmation sent for order #${order.number} to: ${order.userEmail}`);
} else {
console.log("⚠️ No customer email found, skipping customer notification");
}
} catch (error) {
console.error("❌ Failed to send customer email:", error);
}
} catch (error) {
console.error("❌ Failed to send customer email:", error);
} else {
console.log("⏭️ Customer notification disabled, skipping");
}
return res.status(200).end();
+41 -10
View File
@@ -5,6 +5,7 @@ import {
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
import { sendOrderShippedEmail, formatPrice } from "@/lib/resend";
import { settingsStore } from "@/lib/settings";
export const orderFulfilledWebhook = new SaleorAsyncWebhook<OrderFulfilledWebhookPayloadFragment>({
name: "Order Fulfilled in Saleor",
@@ -23,7 +24,17 @@ export default orderFulfilledWebhook.createHandler(async (req, res, ctx) => {
return res.status(200).end();
}
console.log(`Order ${order.number} fulfilled for customer: ${order.userEmail}`);
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,
@@ -41,16 +52,36 @@ export default orderFulfilledWebhook.createHandler(async (req, res, ctx) => {
items,
};
try {
if (order.userEmail) {
await sendOrderShippedEmail({
to: order.userEmail,
orderData,
});
console.log(`Customer notification sent for fulfilled order ${order.number}`);
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);
}
} catch (error) {
console.error("Failed to send 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();
+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;
+25 -2
View File
@@ -3,7 +3,9 @@ import { SaleorApp } from "@saleor/app-sdk/saleor-app";
import { FileAPL } from "@saleor/app-sdk/APL/file";
/**
* APL wrapper that normalizes HTTP to HTTPS for auth data lookups
* APL wrapper that:
* 1. Normalizes HTTP to HTTPS for auth data lookups (for Cloudflare compatibility)
* 2. Prefers SALEOR_API_URL env var over stored auth URL (for internal networking)
*/
class NormalizingAPL implements APL {
private apl: FileAPL;
@@ -16,10 +18,31 @@ class NormalizingAPL implements APL {
return url.replace(/^http:\/\//, "https://");
}
/**
* Get auth data and optionally override saleorApiUrl with env var
*/
async get(saleorApiUrl: string): Promise<AuthData | undefined> {
const normalizedUrl = this.normalizeUrl(saleorApiUrl);
console.log(`[NormalizingAPL] Looking up auth for: ${saleorApiUrl} -> ${normalizedUrl}`);
return this.apl.get(normalizedUrl);
const authData = await this.apl.get(normalizedUrl);
if (!authData) {
return undefined;
}
// If SALEOR_API_URL is set, prefer it over the stored URL
// This enables internal networking (e.g., http://saleor-api.saleor:8000)
// while still using the stored token for authentication
if (process.env.SALEOR_API_URL) {
console.log(`[NormalizingAPL] Using SALEOR_API_URL: ${process.env.SALEOR_API_URL} (env var overrides stored: ${authData.saleorApiUrl})`);
return {
...authData,
saleorApiUrl: process.env.SALEOR_API_URL,
};
}
return authData;
}
async set(authData: AuthData): Promise<void> {