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
This commit is contained in:
Unchained
2026-03-27 07:16:03 +02:00
parent 7b52ab585a
commit 41fd8a5099
7 changed files with 1403 additions and 75 deletions
+699 -27
View File
File diff suppressed because it is too large Load Diff
+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();
+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 });
}
}
+34 -3
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"),
};
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}`);
console.log(`Customer notification sent for cancelled order ${order.number}`);
}
} catch (error) {
console.error("Failed to send email:", error);
console.error("Failed to send customer email:", error);
}
} else {
console.log("⏭️ Customer notification disabled, skipping");
}
return res.status(200).end();
+19 -2
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,7 +61,7 @@ export default orderCreatedWebhook.createHandler(async (req, res, ctx) => {
phone: (order as any).shippingAddress?.phone,
};
// Send admin notification
if (sendAdmin) {
try {
const adminEmails = process.env.ADMIN_EMAILS?.split(",").map(e => e.trim()).filter(e => e) || [];
@@ -67,8 +78,11 @@ export default orderCreatedWebhook.createHandler(async (req, res, ctx) => {
} catch (error) {
console.error("❌ Failed to send admin email:", error);
}
} else {
console.log("⏭️ Admin notification disabled, skipping");
}
// Send customer confirmation
if (sendCustomer) {
try {
if (order.userEmail) {
await sendOrderConfirmationEmail({
@@ -83,6 +97,9 @@ export default orderCreatedWebhook.createHandler(async (req, res, ctx) => {
} catch (error) {
console.error("❌ Failed to send customer email:", error);
}
} else {
console.log("⏭️ Customer notification disabled, skipping");
}
return res.status(200).end();
});
+34 -3
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,
};
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}`);
console.log(`Customer notification sent for fulfilled order ${order.number}`);
}
} catch (error) {
console.error("Failed to send email:", 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;