Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 513a8b7866 | |||
| 41fd8a5099 | |||
| 7b52ab585a | |||
| debb1365b5 |
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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
|
||||
|
||||
Generated
+699
-27
File diff suppressed because it is too large
Load Diff
+31
-19
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user