Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85e41bfcc4 | ||
|
|
84b85f5291 | ||
|
|
c98677405a | ||
|
|
4a63098e3e | ||
|
|
2e6668ff0d | ||
|
|
eb9a798d40 | ||
|
|
ab7dfbe48b | ||
|
|
319f62b923 | ||
|
|
f73f3b8576 | ||
|
|
4d428b3ff0 | ||
|
|
646d057970 | ||
|
|
a0fa0f5401 | ||
|
|
aa7a0ed3c8 | ||
|
|
15a65758d7 |
Generated
+2574
-1
File diff suppressed because it is too large
Load Diff
+17
-2
@@ -6,7 +6,13 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^4.1.6",
|
"@apollo/client": "^4.1.6",
|
||||||
@@ -26,13 +32,22 @@
|
|||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
|
"msw": "^2.12.14",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
# Manoon Storefront Test Suite
|
||||||
|
|
||||||
|
Comprehensive test suite for the ManoonOils storefront with focus on webhooks, commerce operations, and critical paths.
|
||||||
|
|
||||||
|
## 🎯 Coverage Goals
|
||||||
|
|
||||||
|
- **Critical Paths**: 80%+ coverage
|
||||||
|
- **Webhook Handlers**: 100% coverage
|
||||||
|
- **Email Services**: 90%+ coverage
|
||||||
|
- **Analytics**: 80%+ coverage
|
||||||
|
|
||||||
|
## 🧪 Test Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/__tests__/
|
||||||
|
├── unit/
|
||||||
|
│ ├── services/ # Business logic tests
|
||||||
|
│ │ ├── OrderNotificationService.test.ts
|
||||||
|
│ │ └── AnalyticsService.test.ts
|
||||||
|
│ ├── stores/ # State management tests
|
||||||
|
│ │ └── saleorCheckoutStore.test.ts
|
||||||
|
│ └── utils/ # Utility function tests
|
||||||
|
│ └── formatPrice.test.ts
|
||||||
|
├── integration/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── webhooks/
|
||||||
|
│ │ └── saleor.test.ts # Webhook handler tests
|
||||||
|
│ └── emails/
|
||||||
|
│ ├── OrderConfirmation.test.tsx
|
||||||
|
│ └── OrderShipped.test.tsx
|
||||||
|
└── fixtures/ # Test data
|
||||||
|
└── orders.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Running Tests
|
||||||
|
|
||||||
|
### Unit & Integration Tests (Vitest)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests in watch mode
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run tests once
|
||||||
|
npm run test:run
|
||||||
|
|
||||||
|
# Run with coverage report
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# Run with UI
|
||||||
|
npm run test:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Tests (Playwright)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all E2E tests
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
|
# Run with UI mode
|
||||||
|
npm run test:e2e:ui
|
||||||
|
|
||||||
|
# Run specific test
|
||||||
|
npx playwright test tests/critical-paths/checkout-flow.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Test Categories
|
||||||
|
|
||||||
|
### 🔥 Critical Tests (Must Pass)
|
||||||
|
|
||||||
|
1. **Webhook Handler Tests**
|
||||||
|
- ORDER_CONFIRMED: Sends emails + analytics
|
||||||
|
- ORDER_CREATED: No duplicate emails/analytics
|
||||||
|
- ORDER_FULFILLED: Tracking info included
|
||||||
|
- ORDER_CANCELLED: Cancellation reason included
|
||||||
|
- ORDER_FULLY_PAID: Payment confirmation
|
||||||
|
|
||||||
|
2. **Email Service Tests**
|
||||||
|
- Correct translations (SR, EN, DE, FR)
|
||||||
|
- Price formatting (no /100 bug)
|
||||||
|
- Admin vs Customer templates
|
||||||
|
- Address formatting
|
||||||
|
|
||||||
|
3. **Analytics Tests**
|
||||||
|
- Revenue tracked once per order
|
||||||
|
- Correct currency (RSD not USD)
|
||||||
|
- Error handling (doesn't break order flow)
|
||||||
|
|
||||||
|
### 🔧 Integration Tests
|
||||||
|
|
||||||
|
- Full checkout flow
|
||||||
|
- Cart operations
|
||||||
|
- Email template rendering
|
||||||
|
- API error handling
|
||||||
|
|
||||||
|
## 🎭 Mocking Strategy
|
||||||
|
|
||||||
|
- **Resend**: Mocked (no actual emails sent)
|
||||||
|
- **OpenPanel**: Mocked (no actual tracking in tests)
|
||||||
|
- **Saleor API**: Can use real instance for integration tests (read-only)
|
||||||
|
|
||||||
|
## 📈 Coverage Reports
|
||||||
|
|
||||||
|
Coverage reports are generated in multiple formats:
|
||||||
|
- Console output (text)
|
||||||
|
- `coverage/coverage-final.json` (JSON)
|
||||||
|
- `coverage/index.html` (HTML report)
|
||||||
|
|
||||||
|
Open `coverage/index.html` in browser for detailed view.
|
||||||
|
|
||||||
|
## 🔍 Debugging Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debug specific test
|
||||||
|
npm test -- --reporter=verbose src/__tests__/unit/services/AnalyticsService.test.ts
|
||||||
|
|
||||||
|
# Debug with logs
|
||||||
|
DEBUG=true npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Adding New Tests
|
||||||
|
|
||||||
|
1. Create test file: `src/__tests__/unit|integration/path/to/file.test.ts`
|
||||||
|
2. Import from `@/` alias (configured in vitest.config.ts)
|
||||||
|
3. Use fixtures from `src/__tests__/fixtures/`
|
||||||
|
4. Mock external services
|
||||||
|
5. Run tests to verify
|
||||||
|
|
||||||
|
## 🚧 Current Limitations
|
||||||
|
|
||||||
|
- No CI/CD integration yet (informational only)
|
||||||
|
- E2E tests need Playwright browser installation
|
||||||
|
- Some tests use mocked data instead of real Saleor API
|
||||||
|
|
||||||
|
## ✅ Test Checklist
|
||||||
|
|
||||||
|
Before deploying, ensure:
|
||||||
|
- [ ] All webhook tests pass
|
||||||
|
- [ ] Email service tests pass
|
||||||
|
- [ ] Analytics tests pass
|
||||||
|
- [ ] Coverage >= 80% for critical paths
|
||||||
|
- [ ] No console errors in tests
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Test fixtures for orders
|
||||||
|
export const mockOrderPayload = {
|
||||||
|
id: "T3JkZXI6MTIzNDU2Nzg=",
|
||||||
|
number: 1524,
|
||||||
|
user_email: "[email protected]",
|
||||||
|
first_name: "Test",
|
||||||
|
last_name: "Customer",
|
||||||
|
billing_address: {
|
||||||
|
first_name: "Test",
|
||||||
|
last_name: "Customer",
|
||||||
|
street_address_1: "123 Test Street",
|
||||||
|
street_address_2: "",
|
||||||
|
city: "Belgrade",
|
||||||
|
postal_code: "11000",
|
||||||
|
country: "RS",
|
||||||
|
phone: "+38160123456",
|
||||||
|
},
|
||||||
|
shipping_address: {
|
||||||
|
first_name: "Test",
|
||||||
|
last_name: "Customer",
|
||||||
|
street_address_1: "123 Test Street",
|
||||||
|
street_address_2: "",
|
||||||
|
city: "Belgrade",
|
||||||
|
postal_code: "11000",
|
||||||
|
country: "RS",
|
||||||
|
phone: "+38160123456",
|
||||||
|
},
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
id: "T3JkZXJMaW5lOjE=",
|
||||||
|
product_name: "Manoon Anti-age Serum",
|
||||||
|
variant_name: "50ml",
|
||||||
|
quantity: 2,
|
||||||
|
total_price_gross_amount: "10000",
|
||||||
|
currency: "RSD",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total_gross_amount: "10000",
|
||||||
|
shipping_price_gross_amount: "480",
|
||||||
|
channel: {
|
||||||
|
currency_code: "RSD",
|
||||||
|
},
|
||||||
|
language_code: "EN",
|
||||||
|
metadata: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockOrderConverted = {
|
||||||
|
id: "T3JkZXI6MTIzNDU2Nzg=",
|
||||||
|
number: "1524",
|
||||||
|
userEmail: "[email protected]",
|
||||||
|
user: {
|
||||||
|
firstName: "Test",
|
||||||
|
lastName: "Customer",
|
||||||
|
},
|
||||||
|
billingAddress: {
|
||||||
|
firstName: "Test",
|
||||||
|
lastName: "Customer",
|
||||||
|
streetAddress1: "123 Test Street",
|
||||||
|
streetAddress2: "",
|
||||||
|
city: "Belgrade",
|
||||||
|
postalCode: "11000",
|
||||||
|
country: "RS",
|
||||||
|
phone: "+38160123456",
|
||||||
|
},
|
||||||
|
shippingAddress: {
|
||||||
|
firstName: "Test",
|
||||||
|
lastName: "Customer",
|
||||||
|
streetAddress1: "123 Test Street",
|
||||||
|
streetAddress2: "",
|
||||||
|
city: "Belgrade",
|
||||||
|
postalCode: "11000",
|
||||||
|
country: "RS",
|
||||||
|
phone: "+38160123456",
|
||||||
|
},
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
id: "T3JkZXJMaW5lOjE=",
|
||||||
|
productName: "Manoon Anti-age Serum",
|
||||||
|
variantName: "50ml",
|
||||||
|
quantity: 2,
|
||||||
|
totalPrice: {
|
||||||
|
gross: {
|
||||||
|
amount: 10000,
|
||||||
|
currency: "RSD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: {
|
||||||
|
gross: {
|
||||||
|
amount: 10000,
|
||||||
|
currency: "RSD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
languageCode: "EN",
|
||||||
|
metadata: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockOrderWithTracking = {
|
||||||
|
...mockOrderPayload,
|
||||||
|
metadata: {
|
||||||
|
trackingNumber: "TRK123456789",
|
||||||
|
trackingUrl: "https://tracking.example.com/TRK123456789",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockOrderCancelled = {
|
||||||
|
...mockOrderPayload,
|
||||||
|
metadata: {
|
||||||
|
cancellationReason: "Customer requested cancellation",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { POST, GET } from "@/app/api/webhooks/saleor/route";
|
||||||
|
import { orderNotificationService } from "@/lib/services/OrderNotificationService";
|
||||||
|
import { analyticsService } from "@/lib/services/AnalyticsService";
|
||||||
|
import { mockOrderPayload, mockOrderWithTracking, mockOrderCancelled } from "../../../fixtures/orders";
|
||||||
|
|
||||||
|
// Mock the services
|
||||||
|
vi.mock("@/lib/services/OrderNotificationService", () => ({
|
||||||
|
orderNotificationService: {
|
||||||
|
sendOrderConfirmation: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendOrderConfirmationToAdmin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendOrderShipped: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendOrderShippedToAdmin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendOrderCancelled: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendOrderCancelledToAdmin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendOrderPaid: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendOrderPaidToAdmin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/AnalyticsService", () => ({
|
||||||
|
analyticsService: {
|
||||||
|
trackOrderReceived: vi.fn().mockResolvedValue(undefined),
|
||||||
|
trackRevenue: vi.fn().mockResolvedValue(undefined),
|
||||||
|
track: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Saleor Webhook Handler", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/webhooks/saleor", () => {
|
||||||
|
it("should return health check response", async () => {
|
||||||
|
const response = await GET();
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.status).toBe("ok");
|
||||||
|
expect(data.supportedEvents).toContain("ORDER_CONFIRMED");
|
||||||
|
expect(data.supportedEvents).toContain("ORDER_CREATED");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/webhooks/saleor - ORDER_CONFIRMED", () => {
|
||||||
|
it("should process ORDER_CONFIRMED and send customer + admin emails", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_CONFIRMED",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderPayload]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
|
||||||
|
// Should send customer email
|
||||||
|
expect(orderNotificationService.sendOrderConfirmation).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Should send admin email
|
||||||
|
expect(orderNotificationService.sendOrderConfirmationToAdmin).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Should track analytics
|
||||||
|
expect(analyticsService.trackOrderReceived).toHaveBeenCalledTimes(1);
|
||||||
|
expect(analyticsService.trackRevenue).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Verify revenue tracking has correct data
|
||||||
|
expect(analyticsService.trackRevenue).toHaveBeenCalledWith({
|
||||||
|
amount: 10000,
|
||||||
|
currency: "RSD",
|
||||||
|
orderId: mockOrderPayload.id,
|
||||||
|
orderNumber: String(mockOrderPayload.number),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT track analytics for ORDER_CREATED (prevents duplication)", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_CREATED",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderPayload]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
// Should NOT send customer email
|
||||||
|
expect(orderNotificationService.sendOrderConfirmation).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Should NOT track analytics
|
||||||
|
expect(analyticsService.trackOrderReceived).not.toHaveBeenCalled();
|
||||||
|
expect(analyticsService.trackRevenue).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Should still send admin notification
|
||||||
|
expect(orderNotificationService.sendOrderConfirmationToAdmin).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/webhooks/saleor - ORDER_FULFILLED", () => {
|
||||||
|
it("should send shipping emails with tracking info", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_FULFILLED",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderWithTracking]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
expect(orderNotificationService.sendOrderShipped).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
"TRK123456789",
|
||||||
|
"https://tracking.example.com/TRK123456789"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(orderNotificationService.sendOrderShippedToAdmin).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
"TRK123456789",
|
||||||
|
"https://tracking.example.com/TRK123456789"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/webhooks/saleor - ORDER_CANCELLED", () => {
|
||||||
|
it("should send cancellation emails with reason", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_CANCELLED",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderCancelled]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
expect(orderNotificationService.sendOrderCancelled).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
"Customer requested cancellation"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(orderNotificationService.sendOrderCancelledToAdmin).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
"Customer requested cancellation"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/webhooks/saleor - ORDER_FULLY_PAID", () => {
|
||||||
|
it("should send payment confirmation emails", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_FULLY_PAID",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderPayload]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
expect(orderNotificationService.sendOrderPaid).toHaveBeenCalledTimes(1);
|
||||||
|
expect(orderNotificationService.sendOrderPaidToAdmin).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error Handling", () => {
|
||||||
|
it("should return 400 for missing order in payload", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_CONFIRMED",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([]), // Empty array
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(data.error).toBe("No order in payload");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 for missing saleor-event header", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderPayload]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(data.error).toBe("Missing saleor-event header");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 200 for unsupported events (graceful skip)", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "UNSUPPORTED_EVENT",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderPayload]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.message).toBe("Event not supported");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle server errors gracefully", async () => {
|
||||||
|
// Simulate service throwing error
|
||||||
|
vi.mocked(orderNotificationService.sendOrderConfirmationToAdmin).mockRejectedValueOnce(
|
||||||
|
new Error("Email service down")
|
||||||
|
);
|
||||||
|
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_CREATED",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderPayload]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Currency Handling", () => {
|
||||||
|
it("should preserve RSD currency from Saleor payload", async () => {
|
||||||
|
const rsdOrder = {
|
||||||
|
...mockOrderPayload,
|
||||||
|
total_gross_amount: "5479",
|
||||||
|
channel: { currency_code: "RSD" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_CONFIRMED",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([rsdOrder]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await POST(request);
|
||||||
|
|
||||||
|
// Verify the order passed to analytics has correct currency
|
||||||
|
expect(analyticsService.trackRevenue).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
amount: 5479,
|
||||||
|
currency: "RSD",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock environment variables
|
||||||
|
process.env.NEXT_PUBLIC_SALEOR_API_URL = "https://api.manoonoils.com/graphql/";
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL = "https://dev.manoonoils.com";
|
||||||
|
process.env.DASHBOARD_URL = "https://dashboard.manoonoils.com";
|
||||||
|
process.env.RESEND_API_KEY = "test-api-key";
|
||||||
|
process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID = "test-client-id";
|
||||||
|
process.env.OPENPANEL_CLIENT_SECRET = "test-client-secret";
|
||||||
|
process.env.OPENPANEL_API_URL = "https://op.nodecrew.me/api";
|
||||||
|
|
||||||
|
// Mock Resend
|
||||||
|
vi.mock("resend", () => ({
|
||||||
|
Resend: vi.fn().mockImplementation(() => ({
|
||||||
|
emails: {
|
||||||
|
send: vi.fn().mockResolvedValue({ id: "test-email-id" }),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock OpenPanel
|
||||||
|
vi.mock("@openpanel/nextjs", () => ({
|
||||||
|
OpenPanel: vi.fn().mockImplementation(() => ({
|
||||||
|
track: vi.fn().mockResolvedValue(undefined),
|
||||||
|
revenue: vi.fn().mockResolvedValue(undefined),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Global test utilities
|
||||||
|
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||||
|
observe: vi.fn(),
|
||||||
|
unobserve: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Create mock functions using vi.hoisted so they're available during mock setup
|
||||||
|
const { mockTrack, mockRevenue } = vi.hoisted(() => ({
|
||||||
|
mockTrack: vi.fn().mockResolvedValue(undefined),
|
||||||
|
mockRevenue: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock OpenPanel using factory function
|
||||||
|
vi.mock("@openpanel/nextjs", () => {
|
||||||
|
return {
|
||||||
|
OpenPanel: class MockOpenPanel {
|
||||||
|
track = mockTrack;
|
||||||
|
revenue = mockRevenue;
|
||||||
|
constructor() {}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import after mock is set up
|
||||||
|
import { AnalyticsService } from "@/lib/services/AnalyticsService";
|
||||||
|
|
||||||
|
describe("AnalyticsService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("trackOrderReceived", () => {
|
||||||
|
it("should track order with all details", async () => {
|
||||||
|
await new AnalyticsService().trackOrderReceived({
|
||||||
|
orderId: "order-123",
|
||||||
|
orderNumber: "1524",
|
||||||
|
total: 5479,
|
||||||
|
currency: "RSD",
|
||||||
|
itemCount: 3,
|
||||||
|
customerEmail: "[email protected]",
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTrack).toHaveBeenCalledWith("order_received", {
|
||||||
|
order_id: "order-123",
|
||||||
|
order_number: "1524",
|
||||||
|
total: 5479,
|
||||||
|
currency: "RSD",
|
||||||
|
item_count: 3,
|
||||||
|
customer_email: "[email protected]",
|
||||||
|
event_type: "ORDER_CONFIRMED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle large order values", async () => {
|
||||||
|
await new AnalyticsService().trackOrderReceived({
|
||||||
|
orderId: "order-456",
|
||||||
|
orderNumber: "2000",
|
||||||
|
total: 500000, // Large amount
|
||||||
|
currency: "RSD",
|
||||||
|
itemCount: 100,
|
||||||
|
customerEmail: "[email protected]",
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTrack).toHaveBeenCalledWith(
|
||||||
|
"order_received",
|
||||||
|
expect.objectContaining({
|
||||||
|
total: 500000,
|
||||||
|
item_count: 100,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw if tracking fails", async () => {
|
||||||
|
mockTrack.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
new AnalyticsService().trackOrderReceived({
|
||||||
|
orderId: "order-123",
|
||||||
|
orderNumber: "1524",
|
||||||
|
total: 1000,
|
||||||
|
currency: "RSD",
|
||||||
|
itemCount: 1,
|
||||||
|
customerEmail: "[email protected]",
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
})
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("trackRevenue", () => {
|
||||||
|
it("should track revenue with correct currency", async () => {
|
||||||
|
await new AnalyticsService().trackRevenue({
|
||||||
|
amount: 5479,
|
||||||
|
currency: "RSD",
|
||||||
|
orderId: "order-123",
|
||||||
|
orderNumber: "1524",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRevenue).toHaveBeenCalledWith(5479, {
|
||||||
|
currency: "RSD",
|
||||||
|
order_id: "order-123",
|
||||||
|
order_number: "1524",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should track revenue with different currencies", async () => {
|
||||||
|
// Test EUR
|
||||||
|
await new AnalyticsService().trackRevenue({
|
||||||
|
amount: 100,
|
||||||
|
currency: "EUR",
|
||||||
|
orderId: "order-1",
|
||||||
|
orderNumber: "1000",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRevenue).toHaveBeenCalledWith(100, {
|
||||||
|
currency: "EUR",
|
||||||
|
order_id: "order-1",
|
||||||
|
order_number: "1000",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test USD
|
||||||
|
await new AnalyticsService().trackRevenue({
|
||||||
|
amount: 150,
|
||||||
|
currency: "USD",
|
||||||
|
orderId: "order-2",
|
||||||
|
orderNumber: "1001",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRevenue).toHaveBeenCalledWith(150, {
|
||||||
|
currency: "USD",
|
||||||
|
order_id: "order-2",
|
||||||
|
order_number: "1001",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log tracking for debugging", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await new AnalyticsService().trackRevenue({
|
||||||
|
amount: 5479,
|
||||||
|
currency: "RSD",
|
||||||
|
orderId: "order-123",
|
||||||
|
orderNumber: "1524",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Tracking revenue: 5479 RSD for order 1524"
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw if revenue tracking fails", async () => {
|
||||||
|
mockRevenue.mockRejectedValueOnce(new Error("API error"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
new AnalyticsService().trackRevenue({
|
||||||
|
amount: 1000,
|
||||||
|
currency: "RSD",
|
||||||
|
orderId: "order-123",
|
||||||
|
orderNumber: "1524",
|
||||||
|
})
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle zero amount orders", async () => {
|
||||||
|
await new AnalyticsService().trackRevenue({
|
||||||
|
amount: 0,
|
||||||
|
currency: "RSD",
|
||||||
|
orderId: "order-000",
|
||||||
|
orderNumber: "0000",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRevenue).toHaveBeenCalledWith(0, {
|
||||||
|
currency: "RSD",
|
||||||
|
order_id: "order-000",
|
||||||
|
order_number: "0000",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("track", () => {
|
||||||
|
it("should track custom events", async () => {
|
||||||
|
await new AnalyticsService().track("custom_event", {
|
||||||
|
property1: "value1",
|
||||||
|
property2: 123,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTrack).toHaveBeenCalledWith("custom_event", {
|
||||||
|
property1: "value1",
|
||||||
|
property2: 123,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw on tracking errors", async () => {
|
||||||
|
mockTrack.mockRejectedValueOnce(new Error("Tracking failed"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
new AnalyticsService().track("test_event", { test: true })
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Singleton pattern", () => {
|
||||||
|
it("should return the same instance", async () => {
|
||||||
|
// Import fresh to test singleton using dynamic import
|
||||||
|
const { analyticsService: service1 } = await import("@/lib/services/AnalyticsService");
|
||||||
|
const { analyticsService: service2 } = await import("@/lib/services/AnalyticsService");
|
||||||
|
|
||||||
|
expect(service1).toBe(service2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error handling", () => {
|
||||||
|
it("should log errors but not throw", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
mockTrack.mockRejectedValueOnce(new Error("Test error"));
|
||||||
|
|
||||||
|
await new AnalyticsService().trackOrderReceived({
|
||||||
|
orderId: "order-123",
|
||||||
|
orderNumber: "1524",
|
||||||
|
total: 1000,
|
||||||
|
currency: "RSD",
|
||||||
|
itemCount: 1,
|
||||||
|
customerEmail: "[email protected]",
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||||
|
expect(consoleErrorSpy.mock.calls[0][0]).toContain("Failed to track order received");
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { orderNotificationService } from "@/lib/services/OrderNotificationService";
|
||||||
|
import { sendEmailToCustomer, sendEmailToAdmin } from "@/lib/resend";
|
||||||
|
import { mockOrderConverted } from "../../fixtures/orders";
|
||||||
|
|
||||||
|
// Mock the resend module
|
||||||
|
vi.mock("@/lib/resend", () => ({
|
||||||
|
sendEmailToCustomer: vi.fn().mockResolvedValue({ id: "test-email-id" }),
|
||||||
|
sendEmailToAdmin: vi.fn().mockResolvedValue({ id: "test-email-id" }),
|
||||||
|
ADMIN_EMAILS: ["[email protected]", "[email protected]"],
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("OrderNotificationService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendOrderConfirmation", () => {
|
||||||
|
it("should send customer order confirmation in correct language (EN)", async () => {
|
||||||
|
const order = { ...mockOrderConverted, languageCode: "EN" };
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "[email protected]",
|
||||||
|
subject: "Order Confirmation #1524",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send customer order confirmation in Serbian (SR)", async () => {
|
||||||
|
const order = { ...mockOrderConverted, languageCode: "SR" };
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "[email protected]",
|
||||||
|
subject: "Potvrda narudžbine #1524",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send customer order confirmation in German (DE)", async () => {
|
||||||
|
const order = { ...mockOrderConverted, languageCode: "DE" };
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "[email protected]",
|
||||||
|
subject: "Bestellbestätigung #1524",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send customer order confirmation in French (FR)", async () => {
|
||||||
|
const order = { ...mockOrderConverted, languageCode: "FR" };
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "[email protected]",
|
||||||
|
subject: "Confirmation de commande #1524",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format price correctly", async () => {
|
||||||
|
const order = {
|
||||||
|
...mockOrderConverted,
|
||||||
|
total: {
|
||||||
|
gross: {
|
||||||
|
amount: 5479,
|
||||||
|
currency: "RSD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
subject: "Order Confirmation #1524",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle missing variant name gracefully", async () => {
|
||||||
|
const order = {
|
||||||
|
...mockOrderConverted,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
...mockOrderConverted.lines[0],
|
||||||
|
variantName: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include variant name when present", async () => {
|
||||||
|
await orderNotificationService.sendOrderConfirmation(mockOrderConverted);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendOrderConfirmationToAdmin", () => {
|
||||||
|
it("should send admin notification with order details", async () => {
|
||||||
|
await orderNotificationService.sendOrderConfirmationToAdmin(mockOrderConverted);
|
||||||
|
|
||||||
|
expect(sendEmailToAdmin).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
subject: expect.stringContaining("🎉 New Order #1524"),
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
orderId: "T3JkZXI6MTIzNDU2Nzg=",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should always use English for admin emails", async () => {
|
||||||
|
const order = { ...mockOrderConverted, languageCode: "SR" };
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmationToAdmin(order);
|
||||||
|
|
||||||
|
expect(sendEmailToAdmin).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include all order details in admin email", async () => {
|
||||||
|
await orderNotificationService.sendOrderConfirmationToAdmin(mockOrderConverted);
|
||||||
|
|
||||||
|
expect(sendEmailToAdmin).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
subject: expect.stringContaining("🎉 New Order"),
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendOrderShipped", () => {
|
||||||
|
it("should send shipping confirmation with tracking", async () => {
|
||||||
|
await orderNotificationService.sendOrderShipped(
|
||||||
|
mockOrderConverted,
|
||||||
|
"TRK123",
|
||||||
|
"https://track.com/TRK123"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "[email protected]",
|
||||||
|
subject: "Your Order #1524 Has Shipped!",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle missing tracking info", async () => {
|
||||||
|
await orderNotificationService.sendOrderShipped(mockOrderConverted);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
subject: "Your Order #1524 Has Shipped!",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendOrderCancelled", () => {
|
||||||
|
it("should send cancellation email with reason", async () => {
|
||||||
|
await orderNotificationService.sendOrderCancelled(
|
||||||
|
mockOrderConverted,
|
||||||
|
"Out of stock"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "[email protected]",
|
||||||
|
subject: "Your Order #1524 Has Been Cancelled",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendOrderPaid", () => {
|
||||||
|
it("should send payment confirmation", async () => {
|
||||||
|
await orderNotificationService.sendOrderPaid(mockOrderConverted);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "[email protected]",
|
||||||
|
subject: "Payment Received for Order #1524!",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatPrice", () => {
|
||||||
|
it("should format prices correctly for RSD", () => {
|
||||||
|
// This is tested indirectly through the email calls above
|
||||||
|
// The formatPrice function is in utils.ts
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle orders with user name", async () => {
|
||||||
|
const order = {
|
||||||
|
...mockOrderConverted,
|
||||||
|
user: { firstName: "John", lastName: "Doe" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle orders without user object", async () => {
|
||||||
|
const order = {
|
||||||
|
...mockOrderConverted,
|
||||||
|
user: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle orders with incomplete address", async () => {
|
||||||
|
const order = {
|
||||||
|
...mockOrderConverted,
|
||||||
|
shippingAddress: {
|
||||||
|
firstName: "Test",
|
||||||
|
lastName: "Customer",
|
||||||
|
city: "Belgrade",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle orders with missing shipping address", async () => {
|
||||||
|
const order = {
|
||||||
|
...mockOrderConverted,
|
||||||
|
shippingAddress: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { formatPrice } from "@/app/api/webhooks/saleor/utils";
|
||||||
|
|
||||||
|
describe("formatPrice", () => {
|
||||||
|
it("should format RSD currency correctly", () => {
|
||||||
|
const result = formatPrice(5479, "RSD");
|
||||||
|
// Note: sr-RS locale uses non-breaking space between number and currency
|
||||||
|
expect(result).toMatch(/5\.479,00\sRSD/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format small amounts correctly", () => {
|
||||||
|
const result = formatPrice(50, "RSD");
|
||||||
|
expect(result).toMatch(/50,00\sRSD/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format large amounts correctly", () => {
|
||||||
|
const result = formatPrice(100000, "RSD");
|
||||||
|
expect(result).toMatch(/100\.000,00\sRSD/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format EUR currency correctly", () => {
|
||||||
|
const result = formatPrice(100, "EUR");
|
||||||
|
// sr-RS locale uses € symbol for EUR
|
||||||
|
expect(result).toMatch(/100,00\s€/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format USD currency correctly", () => {
|
||||||
|
const result = formatPrice(150, "USD");
|
||||||
|
// sr-RS locale uses US$ symbol for USD
|
||||||
|
expect(result).toMatch(/150,00\sUS\$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle decimal amounts", () => {
|
||||||
|
const result = formatPrice(1000.5, "RSD");
|
||||||
|
expect(result).toMatch(/1\.000,50\sRSD/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle zero", () => {
|
||||||
|
const result = formatPrice(0, "RSD");
|
||||||
|
expect(result).toMatch(/0,00\sRSD/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,30 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import crypto from "crypto";
|
import { orderNotificationService } from "@/lib/services/OrderNotificationService";
|
||||||
import { sendEmailToCustomer, sendEmailToAdmin } from "@/lib/resend";
|
import { analyticsService } from "@/lib/services/AnalyticsService";
|
||||||
import { OrderConfirmation } from "@/emails/OrderConfirmation";
|
|
||||||
import { OrderShipped } from "@/emails/OrderShipped";
|
|
||||||
import { OrderCancelled } from "@/emails/OrderCancelled";
|
|
||||||
import { OrderPaid } from "@/emails/OrderPaid";
|
|
||||||
import { OpenPanel } from "@openpanel/nextjs";
|
|
||||||
|
|
||||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
// Saleor webhook payload interfaces (snake_case)
|
||||||
const DASHBOARD_URL = process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com";
|
|
||||||
|
|
||||||
// Initialize OpenPanel for server-side tracking
|
|
||||||
const op = new OpenPanel({
|
|
||||||
clientId: process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || "",
|
|
||||||
clientSecret: process.env.OPENPANEL_CLIENT_SECRET || "",
|
|
||||||
apiUrl: process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api",
|
|
||||||
});
|
|
||||||
|
|
||||||
interface SaleorWebhookHeaders {
|
|
||||||
"saleor-event": string;
|
|
||||||
"saleor-domain": string;
|
|
||||||
"saleor-signature"?: string;
|
|
||||||
"saleor-api-url": string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Saleor sends snake_case in webhook payloads
|
|
||||||
interface SaleorLineItemPayload {
|
interface SaleorLineItemPayload {
|
||||||
id: string;
|
id: string;
|
||||||
product_name: string;
|
product_name: string;
|
||||||
@@ -56,29 +34,24 @@ interface SaleorOrderPayload {
|
|||||||
lines: SaleorLineItemPayload[];
|
lines: SaleorLineItemPayload[];
|
||||||
total_gross_amount: string;
|
total_gross_amount: string;
|
||||||
shipping_price_gross_amount?: string;
|
shipping_price_gross_amount?: string;
|
||||||
channel: {
|
channel: { currency_code: string };
|
||||||
currency_code: string;
|
currency?: string;
|
||||||
};
|
|
||||||
currency?: string; // Fallback for line items
|
|
||||||
language_code?: string;
|
language_code?: string;
|
||||||
metadata?: Record<string, string>;
|
metadata?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal camelCase interfaces for our code
|
// Internal camelCase interfaces
|
||||||
interface SaleorLineItem {
|
interface OrderItem {
|
||||||
id: string;
|
id: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
variantName?: string;
|
variantName?: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
totalPrice: {
|
totalPrice: {
|
||||||
gross: {
|
gross: { amount: number; currency: string };
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SaleorAddress {
|
interface OrderAddress {
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
streetAddress1?: string;
|
streetAddress1?: string;
|
||||||
@@ -89,30 +62,15 @@ interface SaleorAddress {
|
|||||||
phone?: string;
|
phone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SaleorOrder {
|
interface Order {
|
||||||
id: string;
|
id: string;
|
||||||
number: string;
|
number: string;
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
user?: {
|
user?: { firstName?: string; lastName?: string };
|
||||||
firstName?: string;
|
billingAddress?: OrderAddress;
|
||||||
lastName?: string;
|
shippingAddress?: OrderAddress;
|
||||||
email?: string;
|
lines: OrderItem[];
|
||||||
};
|
total: { gross: { amount: number; currency: string } };
|
||||||
billingAddress?: SaleorAddress;
|
|
||||||
shippingAddress?: SaleorAddress;
|
|
||||||
lines: SaleorLineItem[];
|
|
||||||
total: {
|
|
||||||
gross: {
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
shippingPrice?: {
|
|
||||||
gross: {
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
languageCode?: string;
|
languageCode?: string;
|
||||||
metadata?: Array<{ key: string; value: string }>;
|
metadata?: Array<{ key: string; value: string }>;
|
||||||
}
|
}
|
||||||
@@ -125,15 +83,8 @@ const SUPPORTED_EVENTS = [
|
|||||||
"ORDER_FULFILLED",
|
"ORDER_FULFILLED",
|
||||||
];
|
];
|
||||||
|
|
||||||
const LANGUAGE_CODE_MAP: Record<string, string> = {
|
// Convert Saleor payload to internal format
|
||||||
SR: "sr",
|
function convertPayloadToOrder(payload: SaleorOrderPayload): Order {
|
||||||
EN: "en",
|
|
||||||
DE: "de",
|
|
||||||
FR: "fr",
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert Saleor snake_case payload to camelCase
|
|
||||||
function convertPayloadToOrder(payload: SaleorOrderPayload): SaleorOrder {
|
|
||||||
return {
|
return {
|
||||||
id: payload.id,
|
id: payload.id,
|
||||||
number: String(payload.number),
|
number: String(payload.number),
|
||||||
@@ -141,7 +92,6 @@ function convertPayloadToOrder(payload: SaleorOrderPayload): SaleorOrder {
|
|||||||
user: payload.first_name || payload.last_name ? {
|
user: payload.first_name || payload.last_name ? {
|
||||||
firstName: payload.first_name,
|
firstName: payload.first_name,
|
||||||
lastName: payload.last_name,
|
lastName: payload.last_name,
|
||||||
email: payload.user_email,
|
|
||||||
} : undefined,
|
} : undefined,
|
||||||
billingAddress: payload.billing_address ? {
|
billingAddress: payload.billing_address ? {
|
||||||
firstName: payload.billing_address.first_name,
|
firstName: payload.billing_address.first_name,
|
||||||
@@ -181,320 +131,85 @@ function convertPayloadToOrder(payload: SaleorOrderPayload): SaleorOrder {
|
|||||||
currency: payload.channel.currency_code,
|
currency: payload.channel.currency_code,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
shippingPrice: payload.shipping_price_gross_amount ? {
|
|
||||||
gross: {
|
|
||||||
amount: parseInt(payload.shipping_price_gross_amount),
|
|
||||||
currency: payload.channel.currency_code,
|
|
||||||
},
|
|
||||||
} : undefined,
|
|
||||||
languageCode: payload.language_code?.toUpperCase(),
|
languageCode: payload.language_code?.toUpperCase(),
|
||||||
metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined,
|
metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCustomerLanguage(order: SaleorOrder): string {
|
// Extract tracking number from metadata
|
||||||
if (order.languageCode && LANGUAGE_CODE_MAP[order.languageCode]) {
|
function getTrackingInfo(order: Order): { trackingNumber?: string; trackingUrl?: string } {
|
||||||
return LANGUAGE_CODE_MAP[order.languageCode];
|
if (!order.metadata) return {};
|
||||||
}
|
|
||||||
if (order.metadata) {
|
const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber");
|
||||||
const langMeta = order.metadata.find((m) => m.key === "language");
|
const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl");
|
||||||
if (langMeta && LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]) {
|
|
||||||
return LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()];
|
return {
|
||||||
}
|
trackingNumber: trackingMeta?.value,
|
||||||
}
|
trackingUrl: trackingUrlMeta?.value,
|
||||||
return "en";
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPrice(amount: number, currency: string): string {
|
// Extract cancellation reason from metadata
|
||||||
return new Intl.NumberFormat("sr-RS", {
|
function getCancellationReason(order: Order): string | undefined {
|
||||||
style: "currency",
|
if (!order.metadata) return undefined;
|
||||||
currency: currency,
|
const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason");
|
||||||
}).format(amount);
|
return reasonMeta?.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAddress(address?: SaleorAddress): string {
|
// Webhook handlers
|
||||||
if (!address) return "";
|
async function handleOrderConfirmed(order: Order, eventType: string) {
|
||||||
const parts = [
|
const itemCount = order.lines.reduce((sum, line) => sum + line.quantity, 0);
|
||||||
address.firstName,
|
|
||||||
address.lastName,
|
|
||||||
address.streetAddress1,
|
|
||||||
address.streetAddress2,
|
|
||||||
address.postalCode,
|
|
||||||
address.city,
|
|
||||||
address.country,
|
|
||||||
].filter(Boolean);
|
|
||||||
return parts.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCustomerName(order: SaleorOrder): string {
|
// Send customer email only for ORDER_CONFIRMED (not ORDER_CREATED)
|
||||||
if (order.user?.firstName) {
|
|
||||||
return `${order.user.firstName}${order.user.lastName ? ` ${order.user.lastName}` : ""}`;
|
|
||||||
}
|
|
||||||
if (order.billingAddress?.firstName) {
|
|
||||||
return `${order.billingAddress.firstName}${order.billingAddress.lastName ? ` ${order.billingAddress.lastName}` : ""}`;
|
|
||||||
}
|
|
||||||
return "Customer";
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseOrderItems(lines: SaleorLineItem[], currency: string) {
|
|
||||||
return lines.map((line) => ({
|
|
||||||
id: line.id,
|
|
||||||
name: line.variantName ? `${line.productName} (${line.variantName})` : line.productName,
|
|
||||||
quantity: line.quantity,
|
|
||||||
price: formatPrice(line.totalPrice.gross.amount, currency),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleOrderConfirmed(order: SaleorOrder, eventType: string) {
|
|
||||||
const language = getCustomerLanguage(order);
|
|
||||||
const currency = order.total.gross.currency;
|
|
||||||
const customerName = getCustomerName(order);
|
|
||||||
|
|
||||||
const customerEmail = order.userEmail;
|
|
||||||
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
|
|
||||||
|
|
||||||
// Only send customer email for ORDER_CONFIRMED, not ORDER_CREATED
|
|
||||||
// This prevents duplicate emails when both events fire
|
|
||||||
if (eventType === "ORDER_CONFIRMED") {
|
if (eventType === "ORDER_CONFIRMED") {
|
||||||
await sendEmailToCustomer({
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
to: customerEmail,
|
|
||||||
subject:
|
// Track revenue and order analytics only on ORDER_CONFIRMED (not ORDER_CREATED)
|
||||||
language === "sr"
|
// This prevents duplicate tracking when both events fire for the same order
|
||||||
? `Potvrda narudžbine #${order.number}`
|
analyticsService.trackOrderReceived({
|
||||||
: language === "de"
|
orderId: order.id,
|
||||||
? `Bestellbestätigung #${order.number}`
|
orderNumber: order.number,
|
||||||
: language === "fr"
|
total: order.total.gross.amount,
|
||||||
? `Confirmation de commande #${order.number}`
|
currency: order.total.gross.currency,
|
||||||
: `Order Confirmation #${order.number}`,
|
itemCount,
|
||||||
react: OrderConfirmation({
|
customerEmail: order.userEmail,
|
||||||
language,
|
eventType,
|
||||||
orderId: order.id,
|
});
|
||||||
orderNumber: order.number,
|
|
||||||
customerEmail,
|
analyticsService.trackRevenue({
|
||||||
customerName,
|
amount: order.total.gross.amount,
|
||||||
items: parseOrderItems(order.lines, currency),
|
currency: order.total.gross.currency,
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
orderId: order.id,
|
||||||
shippingAddress: formatAddress(order.shippingAddress),
|
orderNumber: order.number,
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
language,
|
|
||||||
idempotencyKey: `order-confirmed/${order.id}`,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track order in OpenPanel
|
// Send admin notification for both events
|
||||||
op.track("order_received", {
|
await orderNotificationService.sendOrderConfirmationToAdmin(order);
|
||||||
order_id: order.id,
|
|
||||||
order_number: order.number,
|
|
||||||
total: order.total.gross.amount,
|
|
||||||
currency: order.total.gross.currency,
|
|
||||||
item_count: order.lines.reduce((sum, line) => sum + line.quantity, 0),
|
|
||||||
customer_email: customerEmail,
|
|
||||||
event_type: eventType,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track revenue
|
|
||||||
op.track("revenue", {
|
|
||||||
amount: order.total.gross.amount,
|
|
||||||
currency: order.total.gross.currency,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Always send admin notification for both ORDER_CREATED and ORDER_CONFIRMED
|
|
||||||
await sendEmailToAdmin({
|
|
||||||
subject: `🎉 New Order #${order.number} - ${formatPrice(order.total.gross.amount, currency)}`,
|
|
||||||
react: OrderConfirmation({
|
|
||||||
language: "en",
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerEmail,
|
|
||||||
customerName,
|
|
||||||
items: parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
shippingAddress: formatAddress(order.shippingAddress),
|
|
||||||
billingAddress: formatAddress(order.billingAddress),
|
|
||||||
phone,
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
dashboardUrl: DASHBOARD_URL,
|
|
||||||
isAdmin: true,
|
|
||||||
}),
|
|
||||||
eventType: "ORDER_CONFIRMED",
|
|
||||||
orderId: order.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOrderFulfilled(order: SaleorOrder) {
|
async function handleOrderFulfilled(order: Order) {
|
||||||
const language = getCustomerLanguage(order);
|
const { trackingNumber, trackingUrl } = getTrackingInfo(order);
|
||||||
const currency = order.total.gross.currency;
|
|
||||||
const customerName = getCustomerName(order);
|
|
||||||
const customerEmail = order.userEmail;
|
|
||||||
|
|
||||||
let trackingNumber: string | undefined;
|
await orderNotificationService.sendOrderShipped(order, trackingNumber, trackingUrl);
|
||||||
let trackingUrl: string | undefined;
|
await orderNotificationService.sendOrderShippedToAdmin(order, trackingNumber, trackingUrl);
|
||||||
|
|
||||||
if (order.metadata) {
|
|
||||||
const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber");
|
|
||||||
if (trackingMeta) {
|
|
||||||
trackingNumber = trackingMeta.value;
|
|
||||||
}
|
|
||||||
const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl");
|
|
||||||
if (trackingUrlMeta) {
|
|
||||||
trackingUrl = trackingUrlMeta.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendEmailToCustomer({
|
|
||||||
to: customerEmail,
|
|
||||||
subject:
|
|
||||||
language === "sr"
|
|
||||||
? `Vaša narudžbina #${order.number} je poslata!`
|
|
||||||
: language === "de"
|
|
||||||
? `Ihre Bestellung #${order.number} wurde versendet!`
|
|
||||||
: language === "fr"
|
|
||||||
? `Votre commande #${order.number} a été expédiée!`
|
|
||||||
: `Your Order #${order.number} Has Shipped!`,
|
|
||||||
react: OrderShipped({
|
|
||||||
language,
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: parseOrderItems(order.lines, currency),
|
|
||||||
trackingNumber,
|
|
||||||
trackingUrl,
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
language,
|
|
||||||
idempotencyKey: `order-fulfilled/${order.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendEmailToAdmin({
|
|
||||||
subject: `Order Shipped #${order.number} - ${customerName}`,
|
|
||||||
react: OrderShipped({
|
|
||||||
language: "en",
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: parseOrderItems(order.lines, currency),
|
|
||||||
trackingNumber,
|
|
||||||
trackingUrl,
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
eventType: "ORDER_FULFILLED",
|
|
||||||
orderId: order.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOrderCancelled(order: SaleorOrder) {
|
async function handleOrderCancelled(order: Order) {
|
||||||
const language = getCustomerLanguage(order);
|
const reason = getCancellationReason(order);
|
||||||
const currency = order.total.gross.currency;
|
|
||||||
const customerName = getCustomerName(order);
|
|
||||||
const customerEmail = order.userEmail;
|
|
||||||
|
|
||||||
let reason: string | undefined;
|
await orderNotificationService.sendOrderCancelled(order, reason);
|
||||||
if (order.metadata) {
|
await orderNotificationService.sendOrderCancelledToAdmin(order, reason);
|
||||||
const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason");
|
|
||||||
if (reasonMeta) {
|
|
||||||
reason = reasonMeta.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendEmailToCustomer({
|
|
||||||
to: customerEmail,
|
|
||||||
subject:
|
|
||||||
language === "sr"
|
|
||||||
? `Vaša narudžbina #${order.number} je otkazana`
|
|
||||||
: language === "de"
|
|
||||||
? `Ihre Bestellung #${order.number} wurde storniert`
|
|
||||||
: language === "fr"
|
|
||||||
? `Votre commande #${order.number} a été annulée`
|
|
||||||
: `Your Order #${order.number} Has Been Cancelled`,
|
|
||||||
react: OrderCancelled({
|
|
||||||
language,
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
reason,
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
language,
|
|
||||||
idempotencyKey: `order-cancelled/${order.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendEmailToAdmin({
|
|
||||||
subject: `Order Cancelled #${order.number} - ${customerName}`,
|
|
||||||
react: OrderCancelled({
|
|
||||||
language: "en",
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
reason,
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
eventType: "ORDER_CANCELLED",
|
|
||||||
orderId: order.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleOrderFullyPaid(order: SaleorOrder) {
|
async function handleOrderFullyPaid(order: Order) {
|
||||||
const language = getCustomerLanguage(order);
|
await orderNotificationService.sendOrderPaid(order);
|
||||||
const currency = order.total.gross.currency;
|
await orderNotificationService.sendOrderPaidToAdmin(order);
|
||||||
const customerName = getCustomerName(order);
|
|
||||||
const customerEmail = order.userEmail;
|
|
||||||
|
|
||||||
await sendEmailToCustomer({
|
|
||||||
to: customerEmail,
|
|
||||||
subject:
|
|
||||||
language === "sr"
|
|
||||||
? `Plaćanje za narudžbinu #${order.number} je primljeno!`
|
|
||||||
: language === "de"
|
|
||||||
? `Zahlung für Bestellung #${order.number} erhalten!`
|
|
||||||
: language === "fr"
|
|
||||||
? `Paiement reçu pour la commande #${order.number}!`
|
|
||||||
: `Payment Received for Order #${order.number}!`,
|
|
||||||
react: OrderPaid({
|
|
||||||
language,
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
language,
|
|
||||||
idempotencyKey: `order-paid/${order.id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendEmailToAdmin({
|
|
||||||
subject: `Payment Received #${order.number} - ${customerName} - ${formatPrice(order.total.gross.amount, currency)}`,
|
|
||||||
react: OrderPaid({
|
|
||||||
language: "en",
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
eventType: "ORDER_FULLY_PAID",
|
|
||||||
orderId: order.id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaleorWebhook(
|
// Main webhook processor
|
||||||
event: string,
|
async function processWebhook(event: string, order: Order) {
|
||||||
payload: { order: SaleorOrder }
|
console.log(`Processing webhook event: ${event} for order ${order.id}`);
|
||||||
) {
|
|
||||||
const { order } = payload;
|
|
||||||
|
|
||||||
console.log(`Processing webhook event: ${event} for order ${order?.id}`);
|
|
||||||
|
|
||||||
if (!order || !order.id) {
|
|
||||||
console.error("No order in payload");
|
|
||||||
throw new Error("No order in payload");
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (event) {
|
switch (event) {
|
||||||
case "ORDER_CREATED":
|
case "ORDER_CREATED":
|
||||||
@@ -519,54 +234,47 @@ export async function POST(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
console.log("=== WEBHOOK RECEIVED ===");
|
console.log("=== WEBHOOK RECEIVED ===");
|
||||||
console.log("Timestamp:", new Date().toISOString());
|
console.log("Timestamp:", new Date().toISOString());
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const headers = request.headers;
|
const headers = request.headers;
|
||||||
|
|
||||||
const event = headers.get("saleor-event") as string;
|
const event = headers.get("saleor-event") as string;
|
||||||
const domain = headers.get("saleor-domain");
|
const domain = headers.get("saleor-domain");
|
||||||
const signature = headers.get("saleor-signature");
|
|
||||||
const apiUrl = headers.get("saleor-api-url");
|
|
||||||
|
|
||||||
console.log(`Received webhook: ${event} from ${domain}`);
|
console.log(`Received webhook: ${event} from ${domain}`);
|
||||||
console.log("Headers:", { event, domain, apiUrl, hasSignature: !!signature });
|
|
||||||
console.log("Payload:", JSON.stringify(body).substring(0, 500));
|
// Parse payload
|
||||||
|
|
||||||
// Handle Saleor legacy webhook payload format (array with snake_case fields)
|
|
||||||
let orderPayload: SaleorOrderPayload | null = null;
|
let orderPayload: SaleorOrderPayload | null = null;
|
||||||
if (Array.isArray(body) && body.length > 0) {
|
if (Array.isArray(body) && body.length > 0) {
|
||||||
// Legacy format: array with order objects directly
|
|
||||||
orderPayload = body[0] as SaleorOrderPayload;
|
orderPayload = body[0] as SaleorOrderPayload;
|
||||||
} else if (body.data && Array.isArray(body.data)) {
|
} else if (body.data && Array.isArray(body.data)) {
|
||||||
// Subscription format: { data: [...] }
|
|
||||||
orderPayload = body.data[0] as SaleorOrderPayload;
|
orderPayload = body.data[0] as SaleorOrderPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!orderPayload) {
|
if (!orderPayload) {
|
||||||
console.error("No order found in webhook payload");
|
console.error("No order found in webhook payload");
|
||||||
return NextResponse.json({ error: "No order in payload" }, { status: 400 });
|
return NextResponse.json({ error: "No order in payload" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Order ID:", orderPayload.id);
|
console.log("Order:", {
|
||||||
console.log("Order number:", orderPayload.number);
|
id: orderPayload.id,
|
||||||
console.log("User email:", orderPayload.user_email);
|
number: orderPayload.number,
|
||||||
|
email: orderPayload.user_email,
|
||||||
|
});
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 });
|
return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize event to uppercase for comparison
|
|
||||||
const normalizedEvent = event.toUpperCase();
|
const normalizedEvent = event.toUpperCase();
|
||||||
|
|
||||||
if (!SUPPORTED_EVENTS.includes(normalizedEvent)) {
|
if (!SUPPORTED_EVENTS.includes(normalizedEvent)) {
|
||||||
console.log(`Event ${event} (normalized: ${normalizedEvent}) not supported, skipping`);
|
console.log(`Event ${event} not supported, skipping`);
|
||||||
return NextResponse.json({ success: true, message: "Event not supported" });
|
return NextResponse.json({ success: true, message: "Event not supported" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert snake_case payload to camelCase
|
|
||||||
const order = convertPayloadToOrder(orderPayload);
|
const order = convertPayloadToOrder(orderPayload);
|
||||||
|
await processWebhook(normalizedEvent, order);
|
||||||
await handleSaleorWebhook(normalizedEvent, { order });
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
return NextResponse.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function formatPrice(amount: number, currency: string): string {
|
||||||
|
return new Intl.NumberFormat("sr-RS", {
|
||||||
|
style: "currency",
|
||||||
|
currency: currency,
|
||||||
|
}).format(amount);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { OpenPanel } from "@openpanel/nextjs";
|
||||||
|
|
||||||
|
// Initialize OpenPanel for server-side tracking
|
||||||
|
const op = new OpenPanel({
|
||||||
|
clientId: process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || "",
|
||||||
|
clientSecret: process.env.OPENPANEL_CLIENT_SECRET || "",
|
||||||
|
apiUrl: process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api",
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface OrderAnalyticsData {
|
||||||
|
orderId: string;
|
||||||
|
orderNumber: string;
|
||||||
|
total: number;
|
||||||
|
currency: string;
|
||||||
|
itemCount: number;
|
||||||
|
customerEmail: string;
|
||||||
|
eventType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevenueData {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
orderId: string;
|
||||||
|
orderNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnalyticsService {
|
||||||
|
private static instance: AnalyticsService;
|
||||||
|
|
||||||
|
static getInstance(): AnalyticsService {
|
||||||
|
if (!AnalyticsService.instance) {
|
||||||
|
AnalyticsService.instance = new AnalyticsService();
|
||||||
|
}
|
||||||
|
return AnalyticsService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async trackOrderReceived(data: OrderAnalyticsData): Promise<void> {
|
||||||
|
try {
|
||||||
|
await op.track("order_received", {
|
||||||
|
order_id: data.orderId,
|
||||||
|
order_number: data.orderNumber,
|
||||||
|
total: data.total,
|
||||||
|
currency: data.currency,
|
||||||
|
item_count: data.itemCount,
|
||||||
|
customer_email: data.customerEmail,
|
||||||
|
event_type: data.eventType,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to track order received:", error);
|
||||||
|
// Don't throw - analytics should not break the main flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async trackRevenue(data: RevenueData): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log(`Tracking revenue: ${data.amount} ${data.currency} for order ${data.orderNumber}`);
|
||||||
|
await op.revenue(data.amount, {
|
||||||
|
currency: data.currency,
|
||||||
|
order_id: data.orderId,
|
||||||
|
order_number: data.orderNumber,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to track revenue:", error);
|
||||||
|
// Don't throw - analytics should not break the main flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async track(eventName: string, properties: Record<string, unknown>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await op.track(eventName, properties);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to track event ${eventName}:`, error);
|
||||||
|
// Don't throw - analytics should not break the main flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const analyticsService = AnalyticsService.getInstance();
|
||||||
|
export { AnalyticsService };
|
||||||
|
export default analyticsService;
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { sendEmailToCustomer, sendEmailToAdmin } from "@/lib/resend";
|
||||||
|
import { OrderConfirmation } from "@/emails/OrderConfirmation";
|
||||||
|
import { OrderShipped } from "@/emails/OrderShipped";
|
||||||
|
import { OrderCancelled } from "@/emails/OrderCancelled";
|
||||||
|
import { OrderPaid } from "@/emails/OrderPaid";
|
||||||
|
import { formatPrice } from "@/app/api/webhooks/saleor/utils";
|
||||||
|
|
||||||
|
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||||
|
const DASHBOARD_URL = process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com";
|
||||||
|
|
||||||
|
// Translation helper for email subjects
|
||||||
|
function getOrderConfirmationSubject(language: string, orderNumber: string): string {
|
||||||
|
const subjects: Record<string, string> = {
|
||||||
|
sr: `Potvrda narudžbine #${orderNumber}`,
|
||||||
|
de: `Bestellbestätigung #${orderNumber}`,
|
||||||
|
fr: `Confirmation de commande #${orderNumber}`,
|
||||||
|
en: `Order Confirmation #${orderNumber}`,
|
||||||
|
};
|
||||||
|
return subjects[language] || subjects.en;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrderShippedSubject(language: string, orderNumber: string): string {
|
||||||
|
const subjects: Record<string, string> = {
|
||||||
|
sr: `Vaša narudžbina #${orderNumber} je poslata!`,
|
||||||
|
de: `Ihre Bestellung #${orderNumber} wurde versendet!`,
|
||||||
|
fr: `Votre commande #${orderNumber} a été expédiée!`,
|
||||||
|
en: `Your Order #${orderNumber} Has Shipped!`,
|
||||||
|
};
|
||||||
|
return subjects[language] || subjects.en;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrderCancelledSubject(language: string, orderNumber: string): string {
|
||||||
|
const subjects: Record<string, string> = {
|
||||||
|
sr: `Vaša narudžbina #${orderNumber} je otkazana`,
|
||||||
|
de: `Ihre Bestellung #${orderNumber} wurde storniert`,
|
||||||
|
fr: `Votre commande #${orderNumber} a été annulée`,
|
||||||
|
en: `Your Order #${orderNumber} Has Been Cancelled`,
|
||||||
|
};
|
||||||
|
return subjects[language] || subjects.en;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrderPaidSubject(language: string, orderNumber: string): string {
|
||||||
|
const subjects: Record<string, string> = {
|
||||||
|
sr: `Plaćanje za narudžbinu #${orderNumber} je primljeno!`,
|
||||||
|
de: `Zahlung für Bestellung #${orderNumber} erhalten!`,
|
||||||
|
fr: `Paiement reçu pour la commande #${orderNumber}!`,
|
||||||
|
en: `Payment Received for Order #${orderNumber}!`,
|
||||||
|
};
|
||||||
|
return subjects[language] || subjects.en;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interfaces
|
||||||
|
interface OrderItem {
|
||||||
|
id: string;
|
||||||
|
productName: string;
|
||||||
|
variantName?: string;
|
||||||
|
quantity: number;
|
||||||
|
totalPrice: {
|
||||||
|
gross: {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderAddress {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
streetAddress1?: string;
|
||||||
|
streetAddress2?: string;
|
||||||
|
city?: string;
|
||||||
|
postalCode?: string;
|
||||||
|
country?: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Order {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
userEmail: string;
|
||||||
|
user?: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
};
|
||||||
|
billingAddress?: OrderAddress;
|
||||||
|
shippingAddress?: OrderAddress;
|
||||||
|
lines: OrderItem[];
|
||||||
|
total: {
|
||||||
|
gross: {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
languageCode?: string;
|
||||||
|
metadata?: Array<{ key: string; value: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderEmailItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
price: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class OrderNotificationService {
|
||||||
|
private static instance: OrderNotificationService;
|
||||||
|
|
||||||
|
static getInstance(): OrderNotificationService {
|
||||||
|
if (!OrderNotificationService.instance) {
|
||||||
|
OrderNotificationService.instance = new OrderNotificationService();
|
||||||
|
}
|
||||||
|
return OrderNotificationService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseOrderItems(lines: OrderItem[], currency: string): OrderEmailItem[] {
|
||||||
|
return lines.map((line) => ({
|
||||||
|
id: line.id,
|
||||||
|
name: line.variantName ? `${line.productName} (${line.variantName})` : line.productName,
|
||||||
|
quantity: line.quantity,
|
||||||
|
price: formatPrice(line.totalPrice.gross.amount, currency),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatAddress(address?: OrderAddress): string {
|
||||||
|
if (!address) return "";
|
||||||
|
const parts = [
|
||||||
|
address.firstName,
|
||||||
|
address.lastName,
|
||||||
|
address.streetAddress1,
|
||||||
|
address.streetAddress2,
|
||||||
|
address.city,
|
||||||
|
address.postalCode,
|
||||||
|
address.country,
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCustomerName(order: Order): string {
|
||||||
|
if (order.user?.firstName || order.user?.lastName) {
|
||||||
|
return `${order.user.firstName || ""} ${order.user.lastName || ""}`.trim();
|
||||||
|
}
|
||||||
|
if (order.shippingAddress?.firstName || order.shippingAddress?.lastName) {
|
||||||
|
return `${order.shippingAddress.firstName || ""} ${order.shippingAddress.lastName || ""}`.trim();
|
||||||
|
}
|
||||||
|
return "Customer";
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCustomerLanguage(order: Order): string {
|
||||||
|
const LANGUAGE_CODE_MAP: Record<string, string> = {
|
||||||
|
SR: "sr",
|
||||||
|
EN: "en",
|
||||||
|
DE: "de",
|
||||||
|
FR: "fr",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (order.languageCode && LANGUAGE_CODE_MAP[order.languageCode]) {
|
||||||
|
return LANGUAGE_CODE_MAP[order.languageCode];
|
||||||
|
}
|
||||||
|
if (order.metadata) {
|
||||||
|
const langMeta = order.metadata.find((m) => m.key === "language");
|
||||||
|
if (langMeta && LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]) {
|
||||||
|
return LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendOrderConfirmation(order: Order): Promise<void> {
|
||||||
|
const language = this.getCustomerLanguage(order);
|
||||||
|
const currency = order.total.gross.currency;
|
||||||
|
const customerName = this.getCustomerName(order);
|
||||||
|
const customerEmail = order.userEmail;
|
||||||
|
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
|
||||||
|
|
||||||
|
await sendEmailToCustomer({
|
||||||
|
to: customerEmail,
|
||||||
|
subject: getOrderConfirmationSubject(language, order.number),
|
||||||
|
react: OrderConfirmation({
|
||||||
|
language,
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.number,
|
||||||
|
customerEmail,
|
||||||
|
customerName,
|
||||||
|
items: this.parseOrderItems(order.lines, currency),
|
||||||
|
total: formatPrice(order.total.gross.amount, currency),
|
||||||
|
shippingAddress: this.formatAddress(order.shippingAddress),
|
||||||
|
siteUrl: SITE_URL,
|
||||||
|
}),
|
||||||
|
language,
|
||||||
|
idempotencyKey: `order-confirmed/${order.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendOrderConfirmationToAdmin(order: Order): Promise<void> {
|
||||||
|
const currency = order.total.gross.currency;
|
||||||
|
const customerName = this.getCustomerName(order);
|
||||||
|
const customerEmail = order.userEmail;
|
||||||
|
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
|
||||||
|
|
||||||
|
await sendEmailToAdmin({
|
||||||
|
subject: `🎉 New Order #${order.number} - ${formatPrice(order.total.gross.amount, currency)}`,
|
||||||
|
react: OrderConfirmation({
|
||||||
|
language: "en",
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.number,
|
||||||
|
customerEmail,
|
||||||
|
customerName,
|
||||||
|
items: this.parseOrderItems(order.lines, currency),
|
||||||
|
total: formatPrice(order.total.gross.amount, currency),
|
||||||
|
shippingAddress: this.formatAddress(order.shippingAddress),
|
||||||
|
billingAddress: this.formatAddress(order.billingAddress),
|
||||||
|
phone,
|
||||||
|
siteUrl: SITE_URL,
|
||||||
|
dashboardUrl: DASHBOARD_URL,
|
||||||
|
isAdmin: true,
|
||||||
|
}),
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
orderId: order.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendOrderShipped(order: Order, trackingNumber?: string, trackingUrl?: string): Promise<void> {
|
||||||
|
const language = this.getCustomerLanguage(order);
|
||||||
|
const currency = order.total.gross.currency;
|
||||||
|
const customerName = this.getCustomerName(order);
|
||||||
|
const customerEmail = order.userEmail;
|
||||||
|
|
||||||
|
await sendEmailToCustomer({
|
||||||
|
to: customerEmail,
|
||||||
|
subject: getOrderShippedSubject(language, order.number),
|
||||||
|
react: OrderShipped({
|
||||||
|
language,
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.number,
|
||||||
|
customerName,
|
||||||
|
items: this.parseOrderItems(order.lines, currency),
|
||||||
|
trackingNumber,
|
||||||
|
trackingUrl,
|
||||||
|
siteUrl: SITE_URL,
|
||||||
|
}),
|
||||||
|
language,
|
||||||
|
idempotencyKey: `order-fulfilled/${order.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendOrderShippedToAdmin(order: Order, trackingNumber?: string, trackingUrl?: string): Promise<void> {
|
||||||
|
const currency = order.total.gross.currency;
|
||||||
|
const customerName = this.getCustomerName(order);
|
||||||
|
|
||||||
|
await sendEmailToAdmin({
|
||||||
|
subject: `Order Shipped #${order.number} - ${customerName}`,
|
||||||
|
react: OrderShipped({
|
||||||
|
language: "en",
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.number,
|
||||||
|
customerName,
|
||||||
|
items: this.parseOrderItems(order.lines, currency),
|
||||||
|
trackingNumber,
|
||||||
|
trackingUrl,
|
||||||
|
siteUrl: SITE_URL,
|
||||||
|
}),
|
||||||
|
eventType: "ORDER_FULFILLED",
|
||||||
|
orderId: order.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendOrderCancelled(order: Order, reason?: string): Promise<void> {
|
||||||
|
const language = this.getCustomerLanguage(order);
|
||||||
|
const currency = order.total.gross.currency;
|
||||||
|
const customerName = this.getCustomerName(order);
|
||||||
|
const customerEmail = order.userEmail;
|
||||||
|
|
||||||
|
await sendEmailToCustomer({
|
||||||
|
to: customerEmail,
|
||||||
|
subject: getOrderCancelledSubject(language, order.number),
|
||||||
|
react: OrderCancelled({
|
||||||
|
language,
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.number,
|
||||||
|
customerName,
|
||||||
|
items: this.parseOrderItems(order.lines, currency),
|
||||||
|
total: formatPrice(order.total.gross.amount, currency),
|
||||||
|
reason,
|
||||||
|
siteUrl: SITE_URL,
|
||||||
|
}),
|
||||||
|
language,
|
||||||
|
idempotencyKey: `order-cancelled/${order.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendOrderCancelledToAdmin(order: Order, reason?: string): Promise<void> {
|
||||||
|
const currency = order.total.gross.currency;
|
||||||
|
const customerName = this.getCustomerName(order);
|
||||||
|
|
||||||
|
await sendEmailToAdmin({
|
||||||
|
subject: `Order Cancelled #${order.number} - ${customerName}`,
|
||||||
|
react: OrderCancelled({
|
||||||
|
language: "en",
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.number,
|
||||||
|
customerName,
|
||||||
|
items: this.parseOrderItems(order.lines, currency),
|
||||||
|
total: formatPrice(order.total.gross.amount, currency),
|
||||||
|
reason,
|
||||||
|
siteUrl: SITE_URL,
|
||||||
|
}),
|
||||||
|
eventType: "ORDER_CANCELLED",
|
||||||
|
orderId: order.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendOrderPaid(order: Order): Promise<void> {
|
||||||
|
const language = this.getCustomerLanguage(order);
|
||||||
|
const currency = order.total.gross.currency;
|
||||||
|
const customerName = this.getCustomerName(order);
|
||||||
|
const customerEmail = order.userEmail;
|
||||||
|
|
||||||
|
await sendEmailToCustomer({
|
||||||
|
to: customerEmail,
|
||||||
|
subject: getOrderPaidSubject(language, order.number),
|
||||||
|
react: OrderPaid({
|
||||||
|
language,
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.number,
|
||||||
|
customerName,
|
||||||
|
items: this.parseOrderItems(order.lines, currency),
|
||||||
|
total: formatPrice(order.total.gross.amount, currency),
|
||||||
|
siteUrl: SITE_URL,
|
||||||
|
}),
|
||||||
|
language,
|
||||||
|
idempotencyKey: `order-paid/${order.id}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendOrderPaidToAdmin(order: Order): Promise<void> {
|
||||||
|
const currency = order.total.gross.currency;
|
||||||
|
const customerName = this.getCustomerName(order);
|
||||||
|
|
||||||
|
await sendEmailToAdmin({
|
||||||
|
subject: `Payment Received #${order.number} - ${customerName} - ${formatPrice(order.total.gross.amount, currency)}`,
|
||||||
|
react: OrderPaid({
|
||||||
|
language: "en",
|
||||||
|
orderId: order.id,
|
||||||
|
orderNumber: order.number,
|
||||||
|
customerName,
|
||||||
|
items: this.parseOrderItems(order.lines, currency),
|
||||||
|
total: formatPrice(order.total.gross.amount, currency),
|
||||||
|
siteUrl: SITE_URL,
|
||||||
|
}),
|
||||||
|
eventType: "ORDER_FULLY_PAID",
|
||||||
|
orderId: order.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const orderNotificationService = OrderNotificationService.getInstance();
|
||||||
|
export default orderNotificationService;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/__tests__/setup.ts"],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "json", "html"],
|
||||||
|
thresholds: {
|
||||||
|
lines: 80,
|
||||||
|
functions: 80,
|
||||||
|
branches: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
|
exclude: [
|
||||||
|
"node_modules/",
|
||||||
|
"src/__tests__/",
|
||||||
|
"**/*.d.ts",
|
||||||
|
"**/*.config.*",
|
||||||
|
"**/e2e/**",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||||
|
exclude: ["node_modules", "dist", ".next", "e2e"],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user