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:
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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/",
|
||||
],
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user