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
This commit is contained in:
Unchained
2026-03-27 05:21:56 +02:00
commit 33fb9a8452
76 changed files with 209115 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
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";
/**
* App SDK helps with the valid Saleor App Manifest creation. Read more:
* https://github.com/saleor/saleor-app-sdk/blob/main/docs/api-handlers.md#manifest-handler-factory
*/
export default createManifestHandler({
async manifestFactory({ appBaseUrl, request, schemaVersion }) {
/**
* Allow to overwrite default app base url, to enable Docker support.
*
* See docs: https://docs.saleor.io/docs/3.x/developer/extending/apps/local-app-development
*/
const iframeBaseUrl = process.env.APP_IFRAME_BASE_URL ?? appBaseUrl;
const apiBaseURL = process.env.APP_API_BASE_URL ?? appBaseUrl;
const extensionsForSaleor3_22: AppExtension[] = [
{
url: apiBaseURL + "/api/server-widget",
permissions: [],
mount: "PRODUCT_DETAILS_WIDGETS",
label: "Product Timestamps",
target: "WIDGET",
options: {
widgetTarget: {
method: "POST",
},
},
},
{
url: iframeBaseUrl+"/client-widget",
permissions: [],
mount: "ORDER_DETAILS_WIDGETS",
label: "Order widget example",
target: "WIDGET",
options: {
widgetTarget: {
method: "GET",
},
},
},
]
const saleorMajor = schemaVersion && schemaVersion[0];
const saleorMinor = schemaVersion && schemaVersion[1]
const is3_22 = saleorMajor === 3 && saleorMinor === 22;
const extensions = is3_22 ? extensionsForSaleor3_22 : [];
const manifest: AppManifest = {
name: "Core Extensions",
tokenTargetUrl: `${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;
},
});
+17
View File
@@ -0,0 +1,17 @@
import { createProtectedHandler } from "@saleor/app-sdk/handlers/next";
import { apl } from "@/saleor-app";
/**
* Will be available only from the Dashboard, and only for users with the MANAGE_ORDERS permission.
*/
const handler = createProtectedHandler(
(req, res, { user, baseUrl, authData }) => {
return res.json({
message: "OK!",
});
},
apl,
["MANAGE_ORDERS"]
);
export default handler;
+21
View File
@@ -0,0 +1,21 @@
// Patch fetch to force HTTPS for api.manoonoils.com
const originalFetch = global.fetch;
global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
let url = input.toString();
if (url.startsWith('http://api.manoonoils.com/')) {
url = url.replace('http://', 'https://');
input = url;
}
return originalFetch(input, init);
};
import { createAppRegisterHandler } from "@saleor/app-sdk/handlers/next";
import { saleorApp } from "@/saleor-app";
export default createAppRegisterHandler({
apl: saleorApp.apl,
allowedSaleorUrls: [
"https://api.manoonoils.com/graphql/",
"http://api.manoonoils.com/graphql/",
],
});
+41
View File
@@ -0,0 +1,41 @@
import { NextApiHandler } from "next";
import { ExtensionPOSTAttributes } from "@saleor/app-sdk/types";
import { verifyJWT } from "@saleor/app-sdk/auth";
import { createClient } from "@/lib/create-graphq-client";
import { ProductTimestampsDocument } from "../../../generated/graphql";
const handler: NextApiHandler = async (req, res) => {
const { appId, accessToken, saleorApiUrl, ...contextParams } =
req.body as ExtensionPOSTAttributes;
res.setHeader("Content-Type", "text/plain");
try {
await verifyJWT({
appId,
token: accessToken,
saleorApiUrl,
});
} catch (e) {
return res.status(401).send("Not authorized");
}
if (!contextParams.productId) {
return res.status(200).send("Missing product ID");
}
const client = createClient(saleorApiUrl, async () => ({ token: accessToken }));
const productTimestamps = await client.query(ProductTimestampsDocument, {
id: contextParams.productId,
});
if (productTimestamps.data?.product) {
return res
.status(200)
.send(
`This product was created at ${productTimestamps.data.product.created} and last updated at ${productTimestamps.data.product.updatedAt}`
);
}
};
export default handler;
+64
View File
@@ -0,0 +1,64 @@
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";
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();
}
console.log(`Order ${order.number} cancelled for customer: ${order.userEmail}`);
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"),
};
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 email:", error);
}
return res.status(200).end();
});
export const config = {
api: {
bodyParser: false,
},
};
+94
View File
@@ -0,0 +1,94 @@
import { SaleorAsyncWebhook } from "@saleor/app-sdk/handlers/next";
import {
OrderCreatedSubscriptionDocument,
OrderCreatedWebhookPayloadFragment,
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
import { sendOrderConfirmationEmail, formatPrice } from "@/lib/resend";
export const orderCreatedWebhook = new SaleorAsyncWebhook<OrderCreatedWebhookPayloadFragment>({
name: "Order Created in Saleor",
webhookPath: "api/webhooks/order-created",
event: "ORDER_CREATED",
apl: saleorApp.apl,
query: OrderCreatedSubscriptionDocument,
});
export default orderCreatedWebhook.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();
}
console.log(`🎉 Order #${order.number} created for customer: ${order.userEmail} (${order.languageCode || "EN"})`);
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,
};
// 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");
}
} catch (error) {
console.error("❌ Failed to send admin email:", error);
}
// 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");
}
} catch (error) {
console.error("❌ Failed to send customer email:", error);
}
return res.status(200).end();
});
export const config = {
api: {
bodyParser: false,
},
};
@@ -0,0 +1,67 @@
import { SaleorSyncWebhook } from "@saleor/app-sdk/handlers/next";
import { FilterShippingMethods } from "@/generated/app-webhooks-types/order-filter-shipping-methods";
import {
OrderFilterShippingMethodsPayloadFragment,
OrderFilterShippingMethodsSubscriptionDocument,
} from "@/generated/graphql";
import { saleorApp } from "@/saleor-app";
/**
* Create abstract Webhook. It decorates handler and performs security checks under the hood.
*
* orderFilterShippingMethodsWebhook.getWebhookManifest() must be called in api/manifest too!
*/
export const orderFilterShippingMethodsWebhook =
new SaleorSyncWebhook<OrderFilterShippingMethodsPayloadFragment>({
name: "Order Filter Shipping Methods",
webhookPath: "api/webhooks/order-filter-shipping-methods",
event: "ORDER_FILTER_SHIPPING_METHODS",
apl: saleorApp.apl,
query: OrderFilterShippingMethodsSubscriptionDocument,
});
/**
* Export decorated Next.js pages router handler, which adds extra context
*/
export default orderFilterShippingMethodsWebhook.createHandler((req, res, ctx) => {
const {
/**
* Access payload from Saleor - defined above
*/
payload,
/**
* Saleor event that triggers the webhook (here - ORDER_FILTER_SHIPPING_METHODS)
*/
event,
/**
* App's URL
*/
baseUrl,
/**
* Auth data (from APL) - contains token and saleorApiUrl that can be used to construct graphQL client
*/
authData,
} = ctx;
/**
* Perform logic based on Saleor Event payload e.g filter shipping methods
* This is a synchronous webhook, so you can return the response directly.
*/
console.log(`Filtering shipping methods for order id: ${payload.order?.id}`);
const response: FilterShippingMethods = {
excluded_methods: [],
};
return res.status(200).json(response);
});
/**
* Disable body parser for this endpoint, so signature can be verified
*/
export const config = {
api: {
bodyParser: false,
},
};
+63
View File
@@ -0,0 +1,63 @@
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";
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();
}
console.log(`Order ${order.number} fulfilled for customer: ${order.userEmail}`);
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,
};
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 email:", error);
}
return res.status(200).end();
});
export const config = {
api: {
bodyParser: false,
},
};