Compare commits
30 Commits
feature/bu
...
test/suite
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85e41bfcc4 | ||
|
|
84b85f5291 | ||
|
|
c98677405a | ||
|
|
4a63098e3e | ||
|
|
2e6668ff0d | ||
|
|
eb9a798d40 | ||
|
|
ab7dfbe48b | ||
|
|
319f62b923 | ||
|
|
f73f3b8576 | ||
|
|
4d428b3ff0 | ||
|
|
646d057970 | ||
|
|
a0fa0f5401 | ||
|
|
aa7a0ed3c8 | ||
|
|
15a65758d7 | ||
|
|
9c2e4e1383 | ||
|
|
d0e3ee3201 | ||
|
|
b5f8ddbaaa | ||
|
|
6dbaf99b29 | ||
|
|
cdd3f9c77e | ||
|
|
17367024c2 | ||
|
|
bf628f873f | ||
|
|
eb311568db | ||
|
|
c9aaacc452 | ||
|
|
e08e919e83 | ||
|
|
923f805d47 | ||
|
|
6e0a05c314 | ||
|
|
5576946829 | ||
|
|
ef83538d0b | ||
|
|
4fcd4b3ba8 | ||
|
|
b8b3a57e6f |
@@ -76,6 +76,14 @@ spec:
|
||||
value: "https://api.manoonoils.com/graphql/"
|
||||
- name: NEXT_PUBLIC_SITE_URL
|
||||
value: "https://dev.manoonoils.com"
|
||||
- name: DASHBOARD_URL
|
||||
value: "https://dashboard.manoonoils.com"
|
||||
- name: NEXT_PUBLIC_OPENPANEL_CLIENT_ID
|
||||
value: "fa61f8ae-0b5d-4187-a9b1-5a04b0025674"
|
||||
- name: OPENPANEL_CLIENT_SECRET
|
||||
value: "91126be0d1e78e657e0427df82733832.c6d30edf6ee673da9650a883604169a13ab8579a0dde70cb39b477f4cf441f90"
|
||||
- name: OPENPANEL_API_URL
|
||||
value: "https://op.nodecrew.me/api"
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
@@ -108,6 +116,16 @@ spec:
|
||||
value: "https://api.manoonoils.com/graphql/"
|
||||
- name: NEXT_PUBLIC_SITE_URL
|
||||
value: "https://dev.manoonoils.com"
|
||||
- name: DASHBOARD_URL
|
||||
value: "https://dashboard.manoonoils.com"
|
||||
- name: RESEND_API_KEY
|
||||
value: "re_bewcjHuy_DHtksWVUxguj8vFzKiJZNkFi"
|
||||
- name: NEXT_PUBLIC_OPENPANEL_CLIENT_ID
|
||||
value: "fa61f8ae-0b5d-4187-a9b1-5a04b0025674"
|
||||
- name: OPENPANEL_CLIENT_SECRET
|
||||
value: "91126be0d1e78e657e0427df82733832.c6d30edf6ee673da9650a883604169a13ab8579a0dde70cb39b477f4cf441f90"
|
||||
- name: OPENPANEL_API_URL
|
||||
value: "https://op.nodecrew.me/api"
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
|
||||
3293
package-lock.json
generated
3293
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -6,10 +6,19 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"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": {
|
||||
"@apollo/client": "^4.1.6",
|
||||
"@openpanel/nextjs": "^1.4.0",
|
||||
"@react-email/components": "^1.0.10",
|
||||
"@react-email/render": "^2.0.4",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.34.4",
|
||||
"graphql": "^16.13.1",
|
||||
@@ -18,17 +27,27 @@
|
||||
"next-intl": "^4.8.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"resend": "^6.9.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@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/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"jsdom": "^29.0.1",
|
||||
"msw": "^2.12.14",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
141
src/__tests__/README.md
Normal file
141
src/__tests__/README.md
Normal file
@@ -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
|
||||
112
src/__tests__/fixtures/orders.ts
Normal file
112
src/__tests__/fixtures/orders.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// Test fixtures for orders
|
||||
export const mockOrderPayload = {
|
||||
id: "T3JkZXI6MTIzNDU2Nzg=",
|
||||
number: 1524,
|
||||
user_email: "test@hytham.me",
|
||||
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: "test@hytham.me",
|
||||
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",
|
||||
},
|
||||
};
|
||||
280
src/__tests__/integration/api/webhooks/saleor.test.ts
Normal file
280
src/__tests__/integration/api/webhooks/saleor.test.ts
Normal file
@@ -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",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
35
src/__tests__/setup.ts
Normal file
35
src/__tests__/setup.ts
Normal file
@@ -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(),
|
||||
}));
|
||||
233
src/__tests__/unit/services/AnalyticsService.test.ts
Normal file
233
src/__tests__/unit/services/AnalyticsService.test.ts
Normal file
@@ -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: "test@example.com",
|
||||
eventType: "ORDER_CONFIRMED",
|
||||
});
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith("order_received", {
|
||||
order_id: "order-123",
|
||||
order_number: "1524",
|
||||
total: 5479,
|
||||
currency: "RSD",
|
||||
item_count: 3,
|
||||
customer_email: "test@example.com",
|
||||
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: "bulk@example.com",
|
||||
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: "test@example.com",
|
||||
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: "test@example.com",
|
||||
eventType: "ORDER_CONFIRMED",
|
||||
});
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
expect(consoleErrorSpy.mock.calls[0][0]).toContain("Failed to track order received");
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
263
src/__tests__/unit/services/OrderNotificationService.test.ts
Normal file
263
src/__tests__/unit/services/OrderNotificationService.test.ts
Normal file
@@ -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: ["me@hytham.me", "tamara@hytham.me"],
|
||||
}));
|
||||
|
||||
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: "test@hytham.me",
|
||||
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: "test@hytham.me",
|
||||
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: "test@hytham.me",
|
||||
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: "test@hytham.me",
|
||||
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: "test@hytham.me",
|
||||
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: "test@hytham.me",
|
||||
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: "test@hytham.me",
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
42
src/__tests__/unit/utils/formatPrice.test.ts
Normal file
42
src/__tests__/unit/utils/formatPrice.test.ts
Normal file
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,16 @@ import Footer from "@/components/layout/Footer";
|
||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||
import { formatPrice } from "@/lib/saleor";
|
||||
import { saleorClient } from "@/lib/saleor/client";
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import {
|
||||
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||
CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||
CHECKOUT_COMPLETE,
|
||||
CHECKOUT_EMAIL_UPDATE,
|
||||
CHECKOUT_METADATA_UPDATE,
|
||||
CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||
} from "@/lib/saleor/mutations/Checkout";
|
||||
import { GET_CHECKOUT_BY_ID } from "@/lib/saleor/queries/Checkout";
|
||||
import type { Checkout } from "@/types/saleor";
|
||||
|
||||
interface ShippingAddressUpdateResponse {
|
||||
@@ -38,6 +43,43 @@ interface CheckoutCompleteResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface EmailUpdateResponse {
|
||||
checkoutEmailUpdate?: {
|
||||
checkout?: Checkout;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface MetadataUpdateResponse {
|
||||
updateMetadata?: {
|
||||
item?: {
|
||||
id: string;
|
||||
metadata?: Array<{ key: string; value: string }>;
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ShippingMethodUpdateResponse {
|
||||
checkoutShippingMethodUpdate?: {
|
||||
checkout?: Checkout;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckoutQueryResponse {
|
||||
checkout?: Checkout;
|
||||
}
|
||||
|
||||
interface ShippingMethod {
|
||||
id: string;
|
||||
name: string;
|
||||
price: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AddressForm {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
@@ -45,7 +87,9 @@ interface AddressForm {
|
||||
streetAddress2: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export default function CheckoutPage() {
|
||||
@@ -53,6 +97,7 @@ export default function CheckoutPage() {
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore();
|
||||
const { trackCheckoutStarted, trackCheckoutStep, trackOrderCompleted, identifyUser } = useAnalytics();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [orderComplete, setOrderComplete] = useState(false);
|
||||
@@ -66,7 +111,9 @@ export default function CheckoutPage() {
|
||||
streetAddress2: "",
|
||||
city: "",
|
||||
postalCode: "",
|
||||
country: "RS",
|
||||
phone: "",
|
||||
email: "",
|
||||
});
|
||||
const [billingAddress, setBillingAddress] = useState<AddressForm>({
|
||||
firstName: "",
|
||||
@@ -75,9 +122,15 @@ export default function CheckoutPage() {
|
||||
streetAddress2: "",
|
||||
city: "",
|
||||
postalCode: "",
|
||||
country: "RS",
|
||||
phone: "",
|
||||
email: "",
|
||||
});
|
||||
|
||||
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
|
||||
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
|
||||
const [showShippingMethods, setShowShippingMethods] = useState(false);
|
||||
|
||||
const lines = getLines();
|
||||
const total = getTotal();
|
||||
|
||||
@@ -87,9 +140,35 @@ export default function CheckoutPage() {
|
||||
}
|
||||
}, [checkout, refreshCheckout]);
|
||||
|
||||
// Track checkout started when page loads
|
||||
useEffect(() => {
|
||||
if (checkout) {
|
||||
const lines = getLines();
|
||||
const total = getTotal();
|
||||
trackCheckoutStarted({
|
||||
total,
|
||||
currency: "RSD",
|
||||
item_count: lines.reduce((sum, line) => sum + line.quantity, 0),
|
||||
items: lines.map(line => ({
|
||||
id: line.variant.id,
|
||||
name: line.variant.product.name,
|
||||
quantity: line.quantity,
|
||||
price: line.variant.pricing?.price?.gross?.amount || 0,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}, [checkout]);
|
||||
|
||||
// Scroll to top when order is complete
|
||||
useEffect(() => {
|
||||
if (orderComplete) {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
}, [orderComplete]);
|
||||
|
||||
const handleShippingChange = (field: keyof AddressForm, value: string) => {
|
||||
setShippingAddress((prev) => ({ ...prev, [field]: value }));
|
||||
if (sameAsShipping) {
|
||||
if (sameAsShipping && field !== "email") {
|
||||
setBillingAddress((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
};
|
||||
@@ -98,6 +177,10 @@ export default function CheckoutPage() {
|
||||
setBillingAddress((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleEmailChange = (value: string) => {
|
||||
setShippingAddress((prev) => ({ ...prev, email: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -106,40 +189,80 @@ export default function CheckoutPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shippingAddress.email || !shippingAddress.email.includes("@")) {
|
||||
setError(t("errorEmailRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shippingAddress.firstName || !shippingAddress.lastName || !shippingAddress.streetAddress1 || !shippingAddress.city || !shippingAddress.postalCode || !shippingAddress.phone) {
|
||||
setError(t("errorFieldsRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const shippingResult = await saleorClient.mutate<ShippingAddressUpdateResponse>({
|
||||
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
shippingAddress: {
|
||||
...shippingAddress,
|
||||
country: "RS",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
|
||||
throw new Error(shippingResult.data.checkoutShippingAddressUpdate.errors[0].message);
|
||||
}
|
||||
// If we're showing shipping methods and one is selected, complete the order
|
||||
if (showShippingMethods && selectedShippingMethod) {
|
||||
console.log("Phase 2: Completing order with shipping method...");
|
||||
|
||||
console.log("Step 1: Updating billing address...");
|
||||
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
||||
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
billingAddress: {
|
||||
...billingAddress,
|
||||
country: "RS",
|
||||
firstName: billingAddress.firstName,
|
||||
lastName: billingAddress.lastName,
|
||||
streetAddress1: billingAddress.streetAddress1,
|
||||
streetAddress2: billingAddress.streetAddress2,
|
||||
city: billingAddress.city,
|
||||
postalCode: billingAddress.postalCode,
|
||||
country: billingAddress.country,
|
||||
phone: billingAddress.phone,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
|
||||
throw new Error(billingResult.data.checkoutBillingAddressUpdate.errors[0].message);
|
||||
throw new Error(`Billing address update failed: ${billingResult.data.checkoutBillingAddressUpdate.errors[0].message}`);
|
||||
}
|
||||
console.log("Step 1: Billing address updated successfully");
|
||||
|
||||
console.log("Step 2: Setting shipping method...");
|
||||
const shippingMethodResult = await saleorClient.mutate<ShippingMethodUpdateResponse>({
|
||||
mutation: CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
shippingMethodId: selectedShippingMethod,
|
||||
},
|
||||
});
|
||||
|
||||
if (shippingMethodResult.data?.checkoutShippingMethodUpdate?.errors && shippingMethodResult.data.checkoutShippingMethodUpdate.errors.length > 0) {
|
||||
throw new Error(`Shipping method update failed: ${shippingMethodResult.data.checkoutShippingMethodUpdate.errors[0].message}`);
|
||||
}
|
||||
console.log("Step 2: Shipping method set successfully");
|
||||
|
||||
console.log("Step 3: Saving phone number...");
|
||||
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
|
||||
mutation: CHECKOUT_METADATA_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
metadata: [
|
||||
{ key: "phone", value: shippingAddress.phone },
|
||||
{ key: "shippingPhone", value: shippingAddress.phone },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (metadataResult.data?.updateMetadata?.errors && metadataResult.data.updateMetadata.errors.length > 0) {
|
||||
console.warn("Failed to save phone metadata:", metadataResult.data.updateMetadata.errors);
|
||||
} else {
|
||||
console.log("Step 3: Phone number saved successfully");
|
||||
}
|
||||
|
||||
console.log("Step 4: Completing checkout...");
|
||||
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
|
||||
mutation: CHECKOUT_COMPLETE,
|
||||
variables: {
|
||||
@@ -155,12 +278,120 @@ export default function CheckoutPage() {
|
||||
if (order) {
|
||||
setOrderNumber(order.number);
|
||||
setOrderComplete(true);
|
||||
|
||||
// Track order completion
|
||||
const lines = getLines();
|
||||
const total = getTotal();
|
||||
trackOrderCompleted({
|
||||
order_id: checkout.id,
|
||||
order_number: order.number,
|
||||
total,
|
||||
currency: "RSD",
|
||||
item_count: lines.reduce((sum, line) => sum + line.quantity, 0),
|
||||
shipping_cost: shippingMethods.find(m => m.id === selectedShippingMethod)?.price.amount,
|
||||
customer_email: shippingAddress.email,
|
||||
});
|
||||
|
||||
// Identify the user
|
||||
identifyUser({
|
||||
profileId: shippingAddress.email,
|
||||
email: shippingAddress.email,
|
||||
firstName: shippingAddress.firstName,
|
||||
lastName: shippingAddress.lastName,
|
||||
});
|
||||
} else {
|
||||
throw new Error(t("errorCreatingOrder"));
|
||||
}
|
||||
} else {
|
||||
// Phase 1: Update email and address, then fetch shipping methods
|
||||
console.log("Phase 1: Updating email and address...");
|
||||
|
||||
console.log("Step 1: Updating email...");
|
||||
const emailResult = await saleorClient.mutate<EmailUpdateResponse>({
|
||||
mutation: CHECKOUT_EMAIL_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
email: shippingAddress.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
|
||||
throw new Error(`Email update failed: ${emailResult.data.checkoutEmailUpdate.errors[0].message}`);
|
||||
}
|
||||
console.log("Step 1: Email updated successfully");
|
||||
|
||||
console.log("Step 2: Updating shipping address...");
|
||||
console.log("Shipping address data:", {
|
||||
firstName: shippingAddress.firstName,
|
||||
lastName: shippingAddress.lastName,
|
||||
streetAddress1: shippingAddress.streetAddress1,
|
||||
city: shippingAddress.city,
|
||||
postalCode: shippingAddress.postalCode,
|
||||
country: shippingAddress.country,
|
||||
phone: shippingAddress.phone,
|
||||
});
|
||||
const shippingResult = await saleorClient.mutate<ShippingAddressUpdateResponse>({
|
||||
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
shippingAddress: {
|
||||
firstName: shippingAddress.firstName,
|
||||
lastName: shippingAddress.lastName,
|
||||
streetAddress1: shippingAddress.streetAddress1,
|
||||
streetAddress2: shippingAddress.streetAddress2,
|
||||
city: shippingAddress.city,
|
||||
postalCode: shippingAddress.postalCode,
|
||||
country: shippingAddress.country,
|
||||
phone: shippingAddress.phone,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
|
||||
throw new Error(`Shipping address update failed: ${shippingResult.data.checkoutShippingAddressUpdate.errors[0].message}`);
|
||||
}
|
||||
console.log("Step 2: Shipping address updated successfully");
|
||||
|
||||
// Query for checkout to get available shipping methods
|
||||
console.log("Step 3: Fetching shipping methods...");
|
||||
const checkoutQueryResult = await saleorClient.query<CheckoutQueryResponse>({
|
||||
query: GET_CHECKOUT_BY_ID,
|
||||
variables: {
|
||||
id: checkout.id,
|
||||
},
|
||||
fetchPolicy: "network-only",
|
||||
});
|
||||
|
||||
const availableMethods = checkoutQueryResult.data?.checkout?.shippingMethods || [];
|
||||
console.log("Available shipping methods:", availableMethods);
|
||||
|
||||
if (availableMethods.length === 0) {
|
||||
throw new Error(t("errorNoShippingMethods"));
|
||||
}
|
||||
|
||||
setShippingMethods(availableMethods);
|
||||
setShowShippingMethods(true);
|
||||
|
||||
// Track shipping step
|
||||
trackCheckoutStep("shipping_method_selection", {
|
||||
available_methods_count: availableMethods.length,
|
||||
});
|
||||
|
||||
// Don't complete yet - show shipping method selection
|
||||
console.log("Phase 1 complete. Waiting for shipping method selection...");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : null;
|
||||
setError(errorMessage || t("errorOccurred"));
|
||||
console.error("Checkout error:", err);
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (err.name === "AbortError") {
|
||||
setError("Request timed out. Please check your connection and try again.");
|
||||
} else {
|
||||
setError(err.message || t("errorOccurred"));
|
||||
}
|
||||
} else {
|
||||
setError(t("errorOccurred"));
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -227,6 +458,36 @@ export default function CheckoutPage() {
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="border-b border-border pb-6">
|
||||
<h2 className="text-xl font-serif mb-4">{t("contactInfo")}</h2>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("email")}</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={shippingAddress.email}
|
||||
onChange={(e) => handleEmailChange(e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<p className="text-xs text-foreground-muted mt-1">{t("emailRequired")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("phone")}</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={shippingAddress.phone}
|
||||
onChange={(e) => handleShippingChange("phone", e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
placeholder="+381..."
|
||||
/>
|
||||
<p className="text-xs text-foreground-muted mt-1">{t("phoneRequired")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-border pb-6">
|
||||
<h2 className="text-xl font-serif mb-4">{t("shippingAddress")}</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -250,6 +511,35 @@ export default function CheckoutPage() {
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">{t("country")}</label>
|
||||
<select
|
||||
required
|
||||
value={shippingAddress.country}
|
||||
onChange={(e) => handleShippingChange("country", e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
>
|
||||
<option value="RS">Serbia (Srbija)</option>
|
||||
<option value="BA">Bosnia and Herzegovina</option>
|
||||
<option value="ME">Montenegro</option>
|
||||
<option value="HR">Croatia</option>
|
||||
<option value="SI">Slovenia</option>
|
||||
<option value="MK">North Macedonia</option>
|
||||
<option value="AL">Albania</option>
|
||||
<option value="XK">Kosovo</option>
|
||||
<option value="BG">Bulgaria</option>
|
||||
<option value="RO">Romania</option>
|
||||
<option value="HU">Hungary</option>
|
||||
<option value="DE">Germany</option>
|
||||
<option value="AT">Austria</option>
|
||||
<option value="CH">Switzerland</option>
|
||||
<option value="FR">France</option>
|
||||
<option value="GB">United Kingdom</option>
|
||||
<option value="US">United States</option>
|
||||
<option value="CA">Canada</option>
|
||||
<option value="AU">Australia</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">{t("streetAddress")}</label>
|
||||
<input
|
||||
@@ -289,16 +579,6 @@ export default function CheckoutPage() {
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium mb-1">{t("phone")}</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={shippingAddress.phone}
|
||||
onChange={(e) => handleShippingChange("phone", e.target.value)}
|
||||
className="w-full border border-border px-4 py-2 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -314,12 +594,49 @@ export default function CheckoutPage() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Shipping Method Selection */}
|
||||
{showShippingMethods && shippingMethods.length > 0 && (
|
||||
<div className="border-b border-border pb-6">
|
||||
<h2 className="text-xl font-serif mb-4">{t("shippingMethod")}</h2>
|
||||
<div className="space-y-3">
|
||||
{shippingMethods.map((method) => (
|
||||
<label
|
||||
key={method.id}
|
||||
className={`flex items-center justify-between p-4 border rounded cursor-pointer transition-colors ${
|
||||
selectedShippingMethod === method.id
|
||||
? "border-foreground bg-background-ice"
|
||||
: "border-border hover:border-foreground/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="radio"
|
||||
name="shippingMethod"
|
||||
value={method.id}
|
||||
checked={selectedShippingMethod === method.id}
|
||||
onChange={(e) => setSelectedShippingMethod(e.target.value)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="font-medium">{method.name}</span>
|
||||
</div>
|
||||
<span className="text-foreground-muted">
|
||||
{formatPrice(method.price.amount)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{!selectedShippingMethod && (
|
||||
<p className="text-red-500 text-sm mt-2">{t("errorSelectShipping")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || lines.length === 0}
|
||||
disabled={isLoading || lines.length === 0 || (showShippingMethods && !selectedShippingMethod)}
|
||||
className="w-full py-4 bg-foreground text-white font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? t("processing") : t("completeOrder", { total: formatPrice(total) })}
|
||||
{isLoading ? t("processing") : showShippingMethods ? t("completeOrder", { total: formatPrice(total) }) : t("continueToShipping")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Metadata } from "next";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
|
||||
import { OpenPanelComponent } from "@openpanel/nextjs";
|
||||
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||
|
||||
@@ -44,8 +45,17 @@ export default async function LocaleLayout({
|
||||
const messages = await getMessages();
|
||||
|
||||
return (
|
||||
<>
|
||||
<OpenPanelComponent
|
||||
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""}
|
||||
trackScreenViews={true}
|
||||
trackOutgoingLinks={true}
|
||||
apiUrl="https://op.nodecrew.me/api"
|
||||
scriptUrl="https://op.nodecrew.me/op1.js"
|
||||
/>
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
5
src/app/api/op/[...path]/route.ts
Normal file
5
src/app/api/op/[...path]/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createRouteHandler } from "@openpanel/nextjs/server";
|
||||
|
||||
export const { GET, POST } = createRouteHandler({
|
||||
apiUrl: "https://op.nodecrew.me/api",
|
||||
});
|
||||
295
src/app/api/webhooks/saleor/route.ts
Normal file
295
src/app/api/webhooks/saleor/route.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { orderNotificationService } from "@/lib/services/OrderNotificationService";
|
||||
import { analyticsService } from "@/lib/services/AnalyticsService";
|
||||
|
||||
// Saleor webhook payload interfaces (snake_case)
|
||||
interface SaleorLineItemPayload {
|
||||
id: string;
|
||||
product_name: string;
|
||||
variant_name?: string;
|
||||
quantity: number;
|
||||
total_price_gross_amount: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
interface SaleorAddressPayload {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
street_address_1?: string;
|
||||
street_address_2?: string;
|
||||
city?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
interface SaleorOrderPayload {
|
||||
id: string;
|
||||
number: number;
|
||||
user_email: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
billing_address?: SaleorAddressPayload;
|
||||
shipping_address?: SaleorAddressPayload;
|
||||
lines: SaleorLineItemPayload[];
|
||||
total_gross_amount: string;
|
||||
shipping_price_gross_amount?: string;
|
||||
channel: { currency_code: string };
|
||||
currency?: string;
|
||||
language_code?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
// Internal camelCase 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 }>;
|
||||
}
|
||||
|
||||
const SUPPORTED_EVENTS = [
|
||||
"ORDER_CREATED",
|
||||
"ORDER_CONFIRMED",
|
||||
"ORDER_FULLY_PAID",
|
||||
"ORDER_CANCELLED",
|
||||
"ORDER_FULFILLED",
|
||||
];
|
||||
|
||||
// Convert Saleor payload to internal format
|
||||
function convertPayloadToOrder(payload: SaleorOrderPayload): Order {
|
||||
return {
|
||||
id: payload.id,
|
||||
number: String(payload.number),
|
||||
userEmail: payload.user_email,
|
||||
user: payload.first_name || payload.last_name ? {
|
||||
firstName: payload.first_name,
|
||||
lastName: payload.last_name,
|
||||
} : undefined,
|
||||
billingAddress: payload.billing_address ? {
|
||||
firstName: payload.billing_address.first_name,
|
||||
lastName: payload.billing_address.last_name,
|
||||
streetAddress1: payload.billing_address.street_address_1,
|
||||
streetAddress2: payload.billing_address.street_address_2,
|
||||
city: payload.billing_address.city,
|
||||
postalCode: payload.billing_address.postal_code,
|
||||
country: payload.billing_address.country,
|
||||
phone: payload.billing_address.phone,
|
||||
} : undefined,
|
||||
shippingAddress: payload.shipping_address ? {
|
||||
firstName: payload.shipping_address.first_name,
|
||||
lastName: payload.shipping_address.last_name,
|
||||
streetAddress1: payload.shipping_address.street_address_1,
|
||||
streetAddress2: payload.shipping_address.street_address_2,
|
||||
city: payload.shipping_address.city,
|
||||
postalCode: payload.shipping_address.postal_code,
|
||||
country: payload.shipping_address.country,
|
||||
phone: payload.shipping_address.phone,
|
||||
} : undefined,
|
||||
lines: payload.lines.map((line) => ({
|
||||
id: line.id,
|
||||
productName: line.product_name,
|
||||
variantName: line.variant_name,
|
||||
quantity: line.quantity,
|
||||
totalPrice: {
|
||||
gross: {
|
||||
amount: parseInt(line.total_price_gross_amount),
|
||||
currency: line.currency || payload.channel.currency_code,
|
||||
},
|
||||
},
|
||||
})),
|
||||
total: {
|
||||
gross: {
|
||||
amount: parseInt(payload.total_gross_amount),
|
||||
currency: payload.channel.currency_code,
|
||||
},
|
||||
},
|
||||
languageCode: payload.language_code?.toUpperCase(),
|
||||
metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract tracking number from metadata
|
||||
function getTrackingInfo(order: Order): { trackingNumber?: string; trackingUrl?: string } {
|
||||
if (!order.metadata) return {};
|
||||
|
||||
const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber");
|
||||
const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl");
|
||||
|
||||
return {
|
||||
trackingNumber: trackingMeta?.value,
|
||||
trackingUrl: trackingUrlMeta?.value,
|
||||
};
|
||||
}
|
||||
|
||||
// Extract cancellation reason from metadata
|
||||
function getCancellationReason(order: Order): string | undefined {
|
||||
if (!order.metadata) return undefined;
|
||||
const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason");
|
||||
return reasonMeta?.value;
|
||||
}
|
||||
|
||||
// Webhook handlers
|
||||
async function handleOrderConfirmed(order: Order, eventType: string) {
|
||||
const itemCount = order.lines.reduce((sum, line) => sum + line.quantity, 0);
|
||||
|
||||
// Send customer email only for ORDER_CONFIRMED (not ORDER_CREATED)
|
||||
if (eventType === "ORDER_CONFIRMED") {
|
||||
await orderNotificationService.sendOrderConfirmation(order);
|
||||
|
||||
// Track revenue and order analytics only on ORDER_CONFIRMED (not ORDER_CREATED)
|
||||
// This prevents duplicate tracking when both events fire for the same order
|
||||
analyticsService.trackOrderReceived({
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
total: order.total.gross.amount,
|
||||
currency: order.total.gross.currency,
|
||||
itemCount,
|
||||
customerEmail: order.userEmail,
|
||||
eventType,
|
||||
});
|
||||
|
||||
analyticsService.trackRevenue({
|
||||
amount: order.total.gross.amount,
|
||||
currency: order.total.gross.currency,
|
||||
orderId: order.id,
|
||||
orderNumber: order.number,
|
||||
});
|
||||
}
|
||||
|
||||
// Send admin notification for both events
|
||||
await orderNotificationService.sendOrderConfirmationToAdmin(order);
|
||||
}
|
||||
|
||||
async function handleOrderFulfilled(order: Order) {
|
||||
const { trackingNumber, trackingUrl } = getTrackingInfo(order);
|
||||
|
||||
await orderNotificationService.sendOrderShipped(order, trackingNumber, trackingUrl);
|
||||
await orderNotificationService.sendOrderShippedToAdmin(order, trackingNumber, trackingUrl);
|
||||
}
|
||||
|
||||
async function handleOrderCancelled(order: Order) {
|
||||
const reason = getCancellationReason(order);
|
||||
|
||||
await orderNotificationService.sendOrderCancelled(order, reason);
|
||||
await orderNotificationService.sendOrderCancelledToAdmin(order, reason);
|
||||
}
|
||||
|
||||
async function handleOrderFullyPaid(order: Order) {
|
||||
await orderNotificationService.sendOrderPaid(order);
|
||||
await orderNotificationService.sendOrderPaidToAdmin(order);
|
||||
}
|
||||
|
||||
// Main webhook processor
|
||||
async function processWebhook(event: string, order: Order) {
|
||||
console.log(`Processing webhook event: ${event} for order ${order.id}`);
|
||||
|
||||
switch (event) {
|
||||
case "ORDER_CREATED":
|
||||
case "ORDER_CONFIRMED":
|
||||
await handleOrderConfirmed(order, event);
|
||||
break;
|
||||
case "ORDER_FULFILLED":
|
||||
await handleOrderFulfilled(order);
|
||||
break;
|
||||
case "ORDER_CANCELLED":
|
||||
await handleOrderCancelled(order);
|
||||
break;
|
||||
case "ORDER_FULLY_PAID":
|
||||
await handleOrderFullyPaid(order);
|
||||
break;
|
||||
default:
|
||||
console.log(`Unsupported event: ${event}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
console.log("=== WEBHOOK RECEIVED ===");
|
||||
console.log("Timestamp:", new Date().toISOString());
|
||||
|
||||
const body = await request.json();
|
||||
const headers = request.headers;
|
||||
|
||||
const event = headers.get("saleor-event") as string;
|
||||
const domain = headers.get("saleor-domain");
|
||||
|
||||
console.log(`Received webhook: ${event} from ${domain}`);
|
||||
|
||||
// Parse payload
|
||||
let orderPayload: SaleorOrderPayload | null = null;
|
||||
if (Array.isArray(body) && body.length > 0) {
|
||||
orderPayload = body[0] as SaleorOrderPayload;
|
||||
} else if (body.data && Array.isArray(body.data)) {
|
||||
orderPayload = body.data[0] as SaleorOrderPayload;
|
||||
}
|
||||
|
||||
if (!orderPayload) {
|
||||
console.error("No order found in webhook payload");
|
||||
return NextResponse.json({ error: "No order in payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
console.log("Order:", {
|
||||
id: orderPayload.id,
|
||||
number: orderPayload.number,
|
||||
email: orderPayload.user_email,
|
||||
});
|
||||
|
||||
if (!event) {
|
||||
return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 });
|
||||
}
|
||||
|
||||
const normalizedEvent = event.toUpperCase();
|
||||
|
||||
if (!SUPPORTED_EVENTS.includes(normalizedEvent)) {
|
||||
console.log(`Event ${event} not supported, skipping`);
|
||||
return NextResponse.json({ success: true, message: "Event not supported" });
|
||||
}
|
||||
|
||||
const order = convertPayloadToOrder(orderPayload);
|
||||
await processWebhook(normalizedEvent, order);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Webhook processing error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error", details: String(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
message: "Saleor webhook endpoint is active",
|
||||
supportedEvents: SUPPORTED_EVENTS,
|
||||
});
|
||||
}
|
||||
6
src/app/api/webhooks/saleor/utils.ts
Normal file
6
src/app/api/webhooks/saleor/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function formatPrice(amount: number, currency: string): string {
|
||||
return new Intl.NumberFormat("sr-RS", {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
}).format(amount);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
|
||||
import HowItWorks from "@/components/home/HowItWorks";
|
||||
import NewsletterSection from "@/components/home/NewsletterSection";
|
||||
import BundleSelector from "@/components/product/BundleSelector";
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
|
||||
interface ProductDetailProps {
|
||||
product: Product;
|
||||
@@ -99,8 +100,25 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
|
||||
const [urgencyIndex, setUrgencyIndex] = useState(0);
|
||||
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
|
||||
const { addLine, openCart } = useSaleorCheckoutStore();
|
||||
const { trackProductView, trackAddToCart } = useAnalytics();
|
||||
const validLocale = isValidLocale(locale) ? locale : "sr";
|
||||
|
||||
// Track product view on mount
|
||||
useEffect(() => {
|
||||
const localized = getLocalizedProduct(product, locale);
|
||||
const baseVariant = product.variants?.[0];
|
||||
const price = baseVariant?.pricing?.price?.gross?.amount || 0;
|
||||
const currency = baseVariant?.pricing?.price?.gross?.currency || "RSD";
|
||||
|
||||
trackProductView({
|
||||
id: product.id,
|
||||
name: localized.name,
|
||||
price,
|
||||
currency,
|
||||
category: product.category?.name,
|
||||
});
|
||||
}, [product, locale]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setUrgencyIndex(prev => (prev + 1) % 3);
|
||||
@@ -132,6 +150,25 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
|
||||
setIsAdding(true);
|
||||
try {
|
||||
await addLine(selectedVariantId, 1);
|
||||
|
||||
// Track add to cart
|
||||
const localized = getLocalizedProduct(product, locale);
|
||||
const baseVariant = product.variants?.[0];
|
||||
const selectedVariant = selectedVariantId === baseVariant?.id
|
||||
? baseVariant
|
||||
: bundleProducts.find(p => p.variants?.[0]?.id === selectedVariantId)?.variants?.[0];
|
||||
const price = selectedVariant?.pricing?.price?.gross?.amount || 0;
|
||||
const currency = selectedVariant?.pricing?.price?.gross?.currency || "RSD";
|
||||
|
||||
trackAddToCart({
|
||||
id: product.id,
|
||||
name: localized.name,
|
||||
price,
|
||||
currency,
|
||||
quantity: 1,
|
||||
variant: selectedVariant?.name,
|
||||
});
|
||||
|
||||
openCart();
|
||||
} finally {
|
||||
setIsAdding(false);
|
||||
|
||||
98
src/emails/BaseLayout.tsx
Normal file
98
src/emails/BaseLayout.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
|
||||
interface BaseLayoutProps {
|
||||
children: React.ReactNode;
|
||||
previewText: string;
|
||||
language: string;
|
||||
siteUrl: string;
|
||||
}
|
||||
|
||||
const translations: Record<string, { footer: string; company: string }> = {
|
||||
sr: {
|
||||
footer: "ManoonOils - Prirodna kozmetika | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
},
|
||||
en: {
|
||||
footer: "ManoonOils - Natural Cosmetics | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
},
|
||||
de: {
|
||||
footer: "ManoonOils - Natürliche Kosmetik | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
},
|
||||
fr: {
|
||||
footer: "ManoonOils - Cosmétiques Naturels | www.manoonoils.com",
|
||||
company: "ManoonOils",
|
||||
},
|
||||
};
|
||||
|
||||
export function BaseLayout({ children, previewText, language, siteUrl }: BaseLayoutProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<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>
|
||||
{children}
|
||||
<Section style={styles.footer}>
|
||||
<Text style={styles.footerText}>{t.footer}</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
body: {
|
||||
backgroundColor: "#f6f6f6",
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: "#ffffff",
|
||||
margin: "0 auto",
|
||||
padding: "40px 20px",
|
||||
maxWidth: "600px",
|
||||
},
|
||||
logoSection: {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "30px",
|
||||
},
|
||||
logo: {
|
||||
margin: "0 auto",
|
||||
},
|
||||
footer: {
|
||||
marginTop: "40px",
|
||||
paddingTop: "20px",
|
||||
borderTop: "1px solid #e0e0e0",
|
||||
},
|
||||
footerText: {
|
||||
color: "#666666",
|
||||
fontSize: "12px",
|
||||
textAlign: "center" as const,
|
||||
},
|
||||
};
|
||||
237
src/emails/OrderCancelled.tsx
Normal file
237
src/emails/OrderCancelled.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { Button, Hr, Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
}
|
||||
|
||||
interface OrderCancelledProps {
|
||||
language: string;
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerName: string;
|
||||
items: OrderItem[];
|
||||
total: string;
|
||||
reason?: string;
|
||||
siteUrl: string;
|
||||
}
|
||||
|
||||
const translations: Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
preview: string;
|
||||
greeting: string;
|
||||
orderCancelled: string;
|
||||
items: string;
|
||||
total: string;
|
||||
reason: string;
|
||||
questions: string;
|
||||
}
|
||||
> = {
|
||||
sr: {
|
||||
title: "Vaša narudžbina je otkazana",
|
||||
preview: "Vaša narudžbina je otkazana",
|
||||
greeting: "Poštovani {name},",
|
||||
orderCancelled:
|
||||
"Vaša narudžbina je otkazana. Ako niste zatražili otkazivanje, molimo kontaktirajte nas što pre.",
|
||||
items: "Artikli",
|
||||
total: "Ukupno",
|
||||
reason: "Razlog",
|
||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||
},
|
||||
en: {
|
||||
title: "Your Order Has Been Cancelled",
|
||||
preview: "Your order has been cancelled",
|
||||
greeting: "Dear {name},",
|
||||
orderCancelled:
|
||||
"Your order has been cancelled. If you did not request this cancellation, please contact us as soon as possible.",
|
||||
items: "Items",
|
||||
total: "Total",
|
||||
reason: "Reason",
|
||||
questions: "Questions? Email us at support@manoonoils.com",
|
||||
},
|
||||
de: {
|
||||
title: "Ihre Bestellung wurde storniert",
|
||||
preview: "Ihre Bestellung wurde storniert",
|
||||
greeting: "Sehr geehrte/r {name},",
|
||||
orderCancelled:
|
||||
"Ihre Bestellung wurde storniert. Wenn Sie diese Stornierung nicht angefordert haben, kontaktieren Sie uns bitte so schnell wie möglich.",
|
||||
items: "Artikel",
|
||||
total: "Gesamt",
|
||||
reason: "Grund",
|
||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||
},
|
||||
fr: {
|
||||
title: "Votre commande a été annulée",
|
||||
preview: "Votre commande a été annulée",
|
||||
greeting: "Cher(e) {name},",
|
||||
orderCancelled:
|
||||
"Votre commande a été annulée. Si vous n'avez pas demandé cette annulation, veuillez nous contacter dès que possible.",
|
||||
items: "Articles",
|
||||
total: "Total",
|
||||
reason: "Raison",
|
||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderCancelled({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerName,
|
||||
items,
|
||||
total,
|
||||
reason,
|
||||
siteUrl,
|
||||
}: OrderCancelledProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||
<Text style={styles.title}>{t.title}</Text>
|
||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||
<Text style={styles.text}>{t.orderCancelled}</Text>
|
||||
|
||||
<Section style={styles.orderInfo}>
|
||||
<Text style={styles.orderNumber}>
|
||||
<strong>Order Number:</strong> {orderNumber}
|
||||
</Text>
|
||||
{reason && (
|
||||
<Text style={styles.reason}>
|
||||
<strong>{t.reason}:</strong> {reason}
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{t.items}</Text>
|
||||
<Hr style={styles.hr} />
|
||||
{items.map((item) => (
|
||||
<Section key={item.id} style={styles.itemRow}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.quantity}x {item.name}
|
||||
</Text>
|
||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||
</Section>
|
||||
))}
|
||||
<Hr style={styles.hr} />
|
||||
<Section style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>{t.total}:</Text>
|
||||
<Text style={styles.totalValue}>{total}</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={siteUrl} style={styles.button}>
|
||||
{language === "sr" ? "Pogledajte proizvode" : "Browse Products"}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={styles.questions}>{t.questions}</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#dc2626",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
greeting: {
|
||||
fontSize: "16px",
|
||||
color: "#333333",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
text: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderInfo: {
|
||||
backgroundColor: "#fef2f2",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderNumber: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0 0 5px 0",
|
||||
},
|
||||
reason: {
|
||||
fontSize: "14px",
|
||||
color: "#991b1b",
|
||||
margin: "0",
|
||||
},
|
||||
itemsSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e0e0e0",
|
||||
margin: "10px 0",
|
||||
},
|
||||
itemRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
itemName: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
itemPrice: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
totalRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
buttonSection: {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "20px",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "12px 30px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
textDecoration: "none",
|
||||
},
|
||||
questions: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
},
|
||||
};
|
||||
394
src/emails/OrderConfirmation.tsx
Normal file
394
src/emails/OrderConfirmation.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import { Button, Hr, Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
}
|
||||
|
||||
interface OrderConfirmationProps {
|
||||
language: string;
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerEmail: string;
|
||||
customerName: string;
|
||||
items: OrderItem[];
|
||||
total: string;
|
||||
shippingAddress?: string;
|
||||
billingAddress?: string;
|
||||
phone?: string;
|
||||
siteUrl: string;
|
||||
dashboardUrl?: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
const translations: Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
preview: string;
|
||||
greeting: string;
|
||||
orderReceived: string;
|
||||
orderNumber: string;
|
||||
items: string;
|
||||
quantity: string;
|
||||
total: string;
|
||||
shippingTo: string;
|
||||
questions: string;
|
||||
thankYou: string;
|
||||
adminTitle: string;
|
||||
adminPreview: string;
|
||||
adminGreeting: string;
|
||||
adminMessage: string;
|
||||
customerLabel: string;
|
||||
customerEmailLabel: string;
|
||||
billingAddressLabel: string;
|
||||
phoneLabel: string;
|
||||
viewDashboard: string;
|
||||
}
|
||||
> = {
|
||||
sr: {
|
||||
title: "Potvrda narudžbine",
|
||||
preview: "Vaša narudžbina je potvrđena",
|
||||
greeting: "Poštovani {name},",
|
||||
orderReceived: "Zahvaljujemo se na Vašoj narudžbini! Primili smo je i sada je u pripremi.",
|
||||
orderNumber: "Broj narudžbine",
|
||||
items: "Artikli",
|
||||
quantity: "Količina",
|
||||
total: "Ukupno",
|
||||
shippingTo: "Adresa za dostavu",
|
||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||
thankYou: "Hvala Vam što kupujete kod nas!",
|
||||
adminTitle: "Nova narudžbina!",
|
||||
adminPreview: "Nova narudžbina je primljena",
|
||||
adminGreeting: "Čestitamo na prodaji!",
|
||||
adminMessage: "Nova narudžbina je upravo primljena. Detalji su ispod:",
|
||||
customerLabel: "Kupac",
|
||||
customerEmailLabel: "Email kupca",
|
||||
billingAddressLabel: "Adresa za naplatu",
|
||||
phoneLabel: "Telefon",
|
||||
viewDashboard: "Pogledaj u Dashboardu",
|
||||
},
|
||||
en: {
|
||||
title: "Order Confirmation",
|
||||
preview: "Your order has been confirmed",
|
||||
greeting: "Dear {name},",
|
||||
orderReceived:
|
||||
"Thank you for your order! We have received it and it is now being processed.",
|
||||
orderNumber: "Order number",
|
||||
items: "Items",
|
||||
quantity: "Quantity",
|
||||
total: "Total",
|
||||
shippingTo: "Shipping address",
|
||||
questions: "Questions? Email us at support@manoonoils.com",
|
||||
thankYou: "Thank you for shopping with us!",
|
||||
adminTitle: "New Order! 🎉",
|
||||
adminPreview: "A new order has been received",
|
||||
adminGreeting: "Congratulations on the sale!",
|
||||
adminMessage: "A new order has just been placed. Details below:",
|
||||
customerLabel: "Customer",
|
||||
customerEmailLabel: "Customer Email",
|
||||
billingAddressLabel: "Billing Address",
|
||||
phoneLabel: "Phone",
|
||||
viewDashboard: "View in Dashboard",
|
||||
},
|
||||
de: {
|
||||
title: "Bestellungsbestätigung",
|
||||
preview: "Ihre Bestellung wurde bestätigt",
|
||||
greeting: "Sehr geehrte/r {name},",
|
||||
orderReceived:
|
||||
"Vielen Dank für Ihre Bestellung! Wir haben sie erhalten und sie wird nun bearbeitet.",
|
||||
orderNumber: "Bestellnummer",
|
||||
items: "Artikel",
|
||||
quantity: "Menge",
|
||||
total: "Gesamt",
|
||||
shippingTo: "Lieferadresse",
|
||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||
thankYou: "Vielen Dank für Ihren Einkauf!",
|
||||
adminTitle: "Neue Bestellung! 🎉",
|
||||
adminPreview: "Eine neue Bestellung wurde erhalten",
|
||||
adminGreeting: "Glückwunsch zum Verkauf!",
|
||||
adminMessage: "Eine neue Bestellung wurde soeben aufgegeben. Details unten:",
|
||||
customerLabel: "Kunde",
|
||||
customerEmailLabel: "Kunden-E-Mail",
|
||||
billingAddressLabel: "Rechnungsadresse",
|
||||
phoneLabel: "Telefon",
|
||||
viewDashboard: "Im Dashboard anzeigen",
|
||||
},
|
||||
fr: {
|
||||
title: "Confirmation de commande",
|
||||
preview: "Votre commande a été confirmée",
|
||||
greeting: "Cher(e) {name},",
|
||||
orderReceived:
|
||||
"Merci pour votre commande! Nous l'avons reçue et elle est en cours de traitement.",
|
||||
orderNumber: "Numéro de commande",
|
||||
items: "Articles",
|
||||
quantity: "Quantité",
|
||||
total: "Total",
|
||||
shippingTo: "Adresse de livraison",
|
||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||
thankYou: "Merci d'avoir Magasiné avec nous!",
|
||||
adminTitle: "Nouvelle commande! 🎉",
|
||||
adminPreview: "Une nouvelle commande a été reçue",
|
||||
adminGreeting: "Félicitations pour la vente!",
|
||||
adminMessage: "Une nouvelle commande vient d'être passée. Détails ci-dessous:",
|
||||
customerLabel: "Client",
|
||||
customerEmailLabel: "Email du client",
|
||||
billingAddressLabel: "Adresse de facturation",
|
||||
phoneLabel: "Téléphone",
|
||||
viewDashboard: "Voir dans le Dashboard",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderConfirmation({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerEmail,
|
||||
customerName,
|
||||
items,
|
||||
total,
|
||||
shippingAddress,
|
||||
billingAddress,
|
||||
phone,
|
||||
siteUrl,
|
||||
dashboardUrl,
|
||||
isAdmin = false,
|
||||
}: OrderConfirmationProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
// For admin emails, always use English
|
||||
const adminT = translations["en"];
|
||||
|
||||
if (isAdmin) {
|
||||
return (
|
||||
<BaseLayout previewText={adminT.adminPreview} language="en" siteUrl={siteUrl}>
|
||||
<Text style={styles.title}>{adminT.adminTitle}</Text>
|
||||
<Text style={styles.greeting}>{adminT.adminGreeting}</Text>
|
||||
<Text style={styles.text}>{adminT.adminMessage}</Text>
|
||||
|
||||
<Section style={styles.orderInfo}>
|
||||
<Text style={styles.orderNumber}>
|
||||
<strong>{adminT.orderNumber}:</strong> {orderNumber}
|
||||
</Text>
|
||||
<Text style={styles.customerInfo}>
|
||||
<strong>{adminT.customerLabel}:</strong> {customerName}
|
||||
</Text>
|
||||
<Text style={styles.customerInfo}>
|
||||
<strong>{adminT.customerEmailLabel}:</strong> {customerEmail}
|
||||
</Text>
|
||||
{phone && (
|
||||
<Text style={styles.customerInfo}>
|
||||
<strong>{adminT.phoneLabel}:</strong> {phone}
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{adminT.items}</Text>
|
||||
<Hr style={styles.hr} />
|
||||
{items.map((item) => (
|
||||
<Section key={item.id} style={styles.itemRow}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.quantity}x {item.name}
|
||||
</Text>
|
||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||
</Section>
|
||||
))}
|
||||
<Hr style={styles.hr} />
|
||||
<Section style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>{adminT.total}:</Text>
|
||||
<Text style={styles.totalValue}>{total}</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
{shippingAddress && (
|
||||
<Section style={styles.shippingSection}>
|
||||
<Text style={styles.sectionTitle}>{adminT.shippingTo}</Text>
|
||||
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{billingAddress && (
|
||||
<Section style={styles.shippingSection}>
|
||||
<Text style={styles.sectionTitle}>{adminT.billingAddressLabel}</Text>
|
||||
<Text style={styles.shippingAddress}>{billingAddress}</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={`${dashboardUrl}/orders/${orderId}`} style={styles.button}>
|
||||
{adminT.viewDashboard}
|
||||
</Button>
|
||||
</Section>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||
<Text style={styles.title}>{t.title}</Text>
|
||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||
<Text style={styles.text}>{t.orderReceived}</Text>
|
||||
|
||||
<Section style={styles.orderInfo}>
|
||||
<Text style={styles.orderNumber}>
|
||||
<strong>{t.orderNumber}:</strong> {orderNumber}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{t.items}</Text>
|
||||
<Hr style={styles.hr} />
|
||||
{items.map((item) => (
|
||||
<Section key={item.id} style={styles.itemRow}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.quantity}x {item.name}
|
||||
</Text>
|
||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||
</Section>
|
||||
))}
|
||||
<Hr style={styles.hr} />
|
||||
<Section style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>{t.total}:</Text>
|
||||
<Text style={styles.totalValue}>{total}</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
{shippingAddress && (
|
||||
<Section style={styles.shippingSection}>
|
||||
<Text style={styles.sectionTitle}>{t.shippingTo}</Text>
|
||||
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={siteUrl} style={styles.button}>
|
||||
{language === "sr"
|
||||
? "Pogledajte narudžbinu"
|
||||
: language === "de"
|
||||
? "Bestellung ansehen"
|
||||
: language === "fr"
|
||||
? "Voir la commande"
|
||||
: "View Order"}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={styles.questions}>{t.questions}</Text>
|
||||
<Text style={styles.thankYou}>{t.thankYou}</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
greeting: {
|
||||
fontSize: "16px",
|
||||
color: "#333333",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
text: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderInfo: {
|
||||
backgroundColor: "#f9f9f9",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderNumber: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0 0 8px 0",
|
||||
},
|
||||
customerInfo: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0 0 4px 0",
|
||||
},
|
||||
itemsSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e0e0e0",
|
||||
margin: "10px 0",
|
||||
},
|
||||
itemRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
itemName: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
itemPrice: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
totalRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
margin: "0",
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
margin: "0",
|
||||
},
|
||||
shippingSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
shippingAddress: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
},
|
||||
buttonSection: {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "20px",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "12px 30px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
textDecoration: "none",
|
||||
},
|
||||
questions: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
thankYou: {
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
},
|
||||
};
|
||||
253
src/emails/OrderPaid.tsx
Normal file
253
src/emails/OrderPaid.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { Button, Hr, Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
}
|
||||
|
||||
interface OrderPaidProps {
|
||||
language: string;
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerName: string;
|
||||
items: OrderItem[];
|
||||
total: string;
|
||||
siteUrl: string;
|
||||
}
|
||||
|
||||
const translations: Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
preview: string;
|
||||
greeting: string;
|
||||
orderPaid: string;
|
||||
items: string;
|
||||
total: string;
|
||||
nextSteps: string;
|
||||
nextStepsText: string;
|
||||
questions: string;
|
||||
}
|
||||
> = {
|
||||
sr: {
|
||||
title: "Plaćanje je primljeno!",
|
||||
preview: "Vaša uplata je zabeležena",
|
||||
greeting: "Poštovani {name},",
|
||||
orderPaid:
|
||||
"Plaćanje za vašu narudžbinu je primljeno. Hvala vam! Narudžbina će uskoro biti spremna za slanje.",
|
||||
items: "Artikli",
|
||||
total: "Ukupno",
|
||||
nextSteps: "Šta dalje?",
|
||||
nextStepsText:
|
||||
"Primićete još jedan email kada vaša narudžbina bude poslata. Možete očekivati dostavu u roku od 3-5 radnih dana.",
|
||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||
},
|
||||
en: {
|
||||
title: "Payment Received!",
|
||||
preview: "Your payment has been recorded",
|
||||
greeting: "Dear {name},",
|
||||
orderPaid:
|
||||
"Payment for your order has been received. Thank you! Your order will be prepared for shipping soon.",
|
||||
items: "Items",
|
||||
total: "Total",
|
||||
nextSteps: "What's next?",
|
||||
nextStepsText:
|
||||
"You will receive another email when your order ships. You can expect delivery within 3-5 business days.",
|
||||
questions: "Questions? Email us at support@manoonoils.com",
|
||||
},
|
||||
de: {
|
||||
title: "Zahlung erhalten!",
|
||||
preview: "Ihre Zahlung wurde verbucht",
|
||||
greeting: "Sehr geehrte/r {name},",
|
||||
orderPaid:
|
||||
"Zahlung für Ihre Bestellung ist eingegangen. Vielen Dank! Ihre Bestellung wird bald für den Versand vorbereitet.",
|
||||
items: "Artikel",
|
||||
total: "Gesamt",
|
||||
nextSteps: "Was kommt als nächstes?",
|
||||
nextStepsText:
|
||||
"Sie erhalten eine weitere E-Mail, wenn Ihre Bestellung versandt wird. Die Lieferung erfolgt innerhalb von 3-5 Werktagen.",
|
||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||
},
|
||||
fr: {
|
||||
title: "Paiement reçu!",
|
||||
preview: "Votre paiement a été enregistré",
|
||||
greeting: "Cher(e) {name},",
|
||||
orderPaid:
|
||||
"Le paiement de votre commande a été reçu. Merci! Votre commande sera bientôt prête à être expédiée.",
|
||||
items: "Articles",
|
||||
total: "Total",
|
||||
nextSteps: "Et ensuite?",
|
||||
nextStepsText:
|
||||
"Vous recevrez un autre email lorsque votre commande sera expédiée. Vous pouvez vous attendre à une livraison dans 3-5 jours ouvrables.",
|
||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderPaid({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerName,
|
||||
items,
|
||||
total,
|
||||
siteUrl,
|
||||
}: OrderPaidProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||
<Text style={styles.title}>{t.title}</Text>
|
||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||
<Text style={styles.text}>{t.orderPaid}</Text>
|
||||
|
||||
<Section style={styles.orderInfo}>
|
||||
<Text style={styles.orderNumber}>
|
||||
<strong>Order Number:</strong> {orderNumber}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{t.items}</Text>
|
||||
<Hr style={styles.hr} />
|
||||
{items.map((item) => (
|
||||
<Section key={item.id} style={styles.itemRow}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.quantity}x {item.name}
|
||||
</Text>
|
||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||
</Section>
|
||||
))}
|
||||
<Hr style={styles.hr} />
|
||||
<Section style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>{t.total}:</Text>
|
||||
<Text style={styles.totalValue}>{total}</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.nextSteps}>
|
||||
<Text style={styles.nextStepsTitle}>{t.nextSteps}</Text>
|
||||
<Text style={styles.nextStepsText}>{t.nextStepsText}</Text>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={siteUrl} style={styles.button}>
|
||||
{language === "sr" ? "Nastavite kupovinu" : "Continue Shopping"}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={styles.questions}>{t.questions}</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#16a34a",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
greeting: {
|
||||
fontSize: "16px",
|
||||
color: "#333333",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
text: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderInfo: {
|
||||
backgroundColor: "#f0fdf4",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderNumber: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
itemsSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e0e0e0",
|
||||
margin: "10px 0",
|
||||
},
|
||||
itemRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
itemName: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
itemPrice: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
totalRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
margin: "0",
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
margin: "0",
|
||||
},
|
||||
nextSteps: {
|
||||
backgroundColor: "#f9f9f9",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
nextStepsTitle: {
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "5px",
|
||||
},
|
||||
nextStepsText: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
},
|
||||
buttonSection: {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "20px",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "12px 30px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
textDecoration: "none",
|
||||
},
|
||||
questions: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
},
|
||||
};
|
||||
193
src/emails/OrderShipped.tsx
Normal file
193
src/emails/OrderShipped.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Button, Hr, Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
interface OrderItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: string;
|
||||
}
|
||||
|
||||
interface OrderShippedProps {
|
||||
language: string;
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
customerName: string;
|
||||
items: OrderItem[];
|
||||
trackingNumber?: string;
|
||||
trackingUrl?: string;
|
||||
siteUrl: string;
|
||||
}
|
||||
|
||||
const translations: Record<
|
||||
string,
|
||||
{
|
||||
title: string;
|
||||
preview: string;
|
||||
greeting: string;
|
||||
orderShipped: string;
|
||||
tracking: string;
|
||||
items: string;
|
||||
questions: string;
|
||||
}
|
||||
> = {
|
||||
sr: {
|
||||
title: "Vaša narudžbina je poslata!",
|
||||
preview: "Vaša narudžbina je na putu",
|
||||
greeting: "Poštovani {name},",
|
||||
orderShipped:
|
||||
"Odlične vesti! Vaša narudžbina je poslata i uskoro će stići na vašu adresu.",
|
||||
tracking: "Praćenje pošiljke",
|
||||
items: "Artikli",
|
||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
||||
},
|
||||
en: {
|
||||
title: "Your Order Has Shipped!",
|
||||
preview: "Your order is on its way",
|
||||
greeting: "Dear {name},",
|
||||
orderShipped:
|
||||
"Great news! Your order has been shipped and will arrive at your address soon.",
|
||||
tracking: "Track your shipment",
|
||||
items: "Items",
|
||||
questions: "Questions? Email us at support@manoonoils.com",
|
||||
},
|
||||
de: {
|
||||
title: "Ihre Bestellung wurde versendet!",
|
||||
preview: "Ihre Bestellung ist unterwegs",
|
||||
greeting: "Sehr geehrte/r {name},",
|
||||
orderShipped:
|
||||
"Großartige Neuigkeiten! Ihre Bestellung wurde versandt und wird in Kürze bei Ihnen eintreffen.",
|
||||
tracking: "Sendung verfolgen",
|
||||
items: "Artikel",
|
||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||
},
|
||||
fr: {
|
||||
title: "Votre commande a été expédiée!",
|
||||
preview: "Votre commande est en route",
|
||||
greeting: "Cher(e) {name},",
|
||||
orderShipped:
|
||||
"Bonne nouvelle! Votre commande a été expédiée et arrivera bientôt à votre adresse.",
|
||||
tracking: "Suivre votre envoi",
|
||||
items: "Articles",
|
||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderShipped({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerName,
|
||||
items,
|
||||
trackingNumber,
|
||||
trackingUrl,
|
||||
siteUrl,
|
||||
}: OrderShippedProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||
<Text style={styles.title}>{t.title}</Text>
|
||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||
<Text style={styles.text}>{t.orderShipped}</Text>
|
||||
|
||||
{trackingNumber && (
|
||||
<Section style={styles.trackingSection}>
|
||||
<Text style={styles.sectionTitle}>{t.tracking}</Text>
|
||||
{trackingUrl ? (
|
||||
<Button href={trackingUrl} style={styles.trackingButton}>
|
||||
{trackingNumber}
|
||||
</Button>
|
||||
) : (
|
||||
<Text style={styles.trackingNumber}>{trackingNumber}</Text>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{t.items}</Text>
|
||||
<Hr style={styles.hr} />
|
||||
{items.map((item) => (
|
||||
<Section key={item.id} style={styles.itemRow}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.quantity}x {item.name}
|
||||
</Text>
|
||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||
</Section>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Text style={styles.questions}>{t.questions}</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
greeting: {
|
||||
fontSize: "16px",
|
||||
color: "#333333",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
text: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
trackingSection: {
|
||||
backgroundColor: "#f9f9f9",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
trackingNumber: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
trackingButton: {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "10px 20px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
textDecoration: "none",
|
||||
},
|
||||
itemsSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e0e0e0",
|
||||
margin: "10px 0",
|
||||
},
|
||||
itemRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
itemName: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
itemPrice: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
questions: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
},
|
||||
};
|
||||
5
src/emails/index.ts
Normal file
5
src/emails/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { BaseLayout } from "./BaseLayout";
|
||||
export { OrderConfirmation } from "./OrderConfirmation";
|
||||
export { OrderShipped } from "./OrderShipped";
|
||||
export { OrderCancelled } from "./OrderCancelled";
|
||||
export { OrderPaid } from "./OrderPaid";
|
||||
@@ -340,7 +340,13 @@
|
||||
},
|
||||
"Checkout": {
|
||||
"checkout": "Kasse",
|
||||
"contactInfo": "Kontaktinformationen",
|
||||
"email": "E-Mail",
|
||||
"emailRequired": "Erforderlich für Bestellbestätigung",
|
||||
"phoneRequired": "Erforderlich für Lieferkoordination",
|
||||
"shippingAddress": "Lieferadresse",
|
||||
"shippingMethod": "Versandart",
|
||||
"country": "Land",
|
||||
"firstName": "Vorname",
|
||||
"lastName": "Nachname",
|
||||
"streetAddress": "Straße und Nummer",
|
||||
@@ -364,6 +370,8 @@
|
||||
"yourCartEmpty": "Ihr Warenkorb ist leer",
|
||||
"continueShopping": "Weiter einkaufen",
|
||||
"errorNoCheckout": "Keine aktive Kasse. Bitte versuchen Sie es erneut.",
|
||||
"errorEmailRequired": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
|
||||
"errorFieldsRequired": "Bitte füllen Sie alle erforderlichen Felder aus.",
|
||||
"errorOccurred": "Ein Fehler ist during des Checkouts aufgetreten.",
|
||||
"errorCreatingOrder": "Bestellung konnte nicht erstellt werden.",
|
||||
"orderConfirmed": "Bestellung bestätigt!",
|
||||
|
||||
@@ -386,7 +386,13 @@
|
||||
},
|
||||
"Checkout": {
|
||||
"checkout": "Checkout",
|
||||
"contactInfo": "Contact Information",
|
||||
"email": "Email",
|
||||
"emailRequired": "Required for order confirmation",
|
||||
"phoneRequired": "Required for delivery coordination",
|
||||
"shippingAddress": "Shipping Address",
|
||||
"shippingMethod": "Shipping Method",
|
||||
"country": "Country",
|
||||
"firstName": "First Name",
|
||||
"lastName": "Last Name",
|
||||
"streetAddress": "Street Address",
|
||||
@@ -410,8 +416,13 @@
|
||||
"yourCartEmpty": "Your cart is empty",
|
||||
"continueShopping": "Continue Shopping",
|
||||
"errorNoCheckout": "No active checkout. Please try again.",
|
||||
"errorEmailRequired": "Please enter a valid email address.",
|
||||
"errorFieldsRequired": "Please fill in all required fields.",
|
||||
"errorNoShippingMethods": "No shipping methods available for this address. Please check your address or contact support.",
|
||||
"errorSelectShipping": "Please select a shipping method.",
|
||||
"errorOccurred": "An error occurred during checkout.",
|
||||
"errorCreatingOrder": "Failed to create order.",
|
||||
"continueToShipping": "Continue to Shipping",
|
||||
"orderConfirmed": "Order Confirmed!",
|
||||
"thankYou": "Thank you for your purchase.",
|
||||
"orderNumber": "Order Number",
|
||||
|
||||
@@ -340,7 +340,13 @@
|
||||
},
|
||||
"Checkout": {
|
||||
"checkout": "Commande",
|
||||
"contactInfo": "Coordonnées",
|
||||
"email": "E-mail",
|
||||
"emailRequired": "Requis pour la confirmation de commande",
|
||||
"phoneRequired": "Requis pour la coordination de livraison",
|
||||
"shippingAddress": "Adresse de Livraison",
|
||||
"shippingMethod": "Méthode de livraison",
|
||||
"country": "Pays",
|
||||
"firstName": "Prénom",
|
||||
"lastName": "Nom",
|
||||
"streetAddress": "Rue et Numéro",
|
||||
@@ -364,6 +370,8 @@
|
||||
"yourCartEmpty": "Votre panier est vide",
|
||||
"continueShopping": "Continuer les Achats",
|
||||
"errorNoCheckout": "Pas de paiement actif. Veuillez réessayer.",
|
||||
"errorEmailRequired": "Veuillez entrer une adresse e-mail valide.",
|
||||
"errorFieldsRequired": "Veuillez remplir tous les champs obligatoires.",
|
||||
"errorOccurred": "Une erreur s'est produite lors du paiement.",
|
||||
"errorCreatingOrder": "Échec de la création de la commande.",
|
||||
"orderConfirmed": "Commande Confirmée!",
|
||||
|
||||
@@ -386,7 +386,13 @@
|
||||
},
|
||||
"Checkout": {
|
||||
"checkout": "Kupovina",
|
||||
"contactInfo": "Kontakt informacije",
|
||||
"email": "Email",
|
||||
"emailRequired": "Potrebno za potvrdu narudžbine",
|
||||
"phoneRequired": "Potrebno za koordinaciju dostave",
|
||||
"shippingAddress": "Adresa za dostavu",
|
||||
"shippingMethod": "Način dostave",
|
||||
"country": "Država",
|
||||
"firstName": "Ime",
|
||||
"lastName": "Prezime",
|
||||
"streetAddress": "Ulica i broj",
|
||||
@@ -410,6 +416,8 @@
|
||||
"yourCartEmpty": "Vaša korpa je prazna",
|
||||
"continueShopping": "Nastavi kupovinu",
|
||||
"errorNoCheckout": "Nema aktivne korpe. Molimo pokušajte ponovo.",
|
||||
"errorEmailRequired": "Molimo unesite validnu email adresu.",
|
||||
"errorFieldsRequired": "Molimo popunite sva obavezna polja.",
|
||||
"errorOccurred": "Došlo je do greške prilikom kupovine.",
|
||||
"errorCreatingOrder": "Neuspešno kreiranje narudžbine.",
|
||||
"orderConfirmed": "Narudžbina potvrđena!",
|
||||
|
||||
154
src/lib/analytics.ts
Normal file
154
src/lib/analytics.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useOpenPanel } from "@openpanel/nextjs";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export function useAnalytics() {
|
||||
const op = useOpenPanel();
|
||||
|
||||
// Page views are tracked automatically by OpenPanelComponent
|
||||
// but we can track specific events manually
|
||||
|
||||
const trackProductView = useCallback((product: {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
category?: string;
|
||||
}) => {
|
||||
op.track("product_viewed", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
category: product.category,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const trackAddToCart = useCallback((product: {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
quantity: number;
|
||||
variant?: string;
|
||||
}) => {
|
||||
op.track("add_to_cart", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
quantity: product.quantity,
|
||||
variant: product.variant,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const trackRemoveFromCart = useCallback((product: {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
}) => {
|
||||
op.track("remove_from_cart", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
quantity: product.quantity,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const trackCheckoutStarted = useCallback((cart: {
|
||||
total: number;
|
||||
currency: string;
|
||||
item_count: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
}>;
|
||||
}) => {
|
||||
op.track("checkout_started", {
|
||||
cart_total: cart.total,
|
||||
currency: cart.currency,
|
||||
item_count: cart.item_count,
|
||||
items: cart.items,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
|
||||
op.track("checkout_step", {
|
||||
step,
|
||||
...data,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const trackOrderCompleted = useCallback((order: {
|
||||
order_id: string;
|
||||
order_number: string;
|
||||
total: number;
|
||||
currency: string;
|
||||
item_count: number;
|
||||
shipping_cost?: number;
|
||||
customer_email?: string;
|
||||
}) => {
|
||||
op.track("order_completed", {
|
||||
order_id: order.order_id,
|
||||
order_number: order.order_number,
|
||||
total: order.total,
|
||||
currency: order.currency,
|
||||
item_count: order.item_count,
|
||||
shipping_cost: order.shipping_cost,
|
||||
customer_email: order.customer_email,
|
||||
});
|
||||
|
||||
// Also track revenue for analytics
|
||||
op.track("purchase", {
|
||||
transaction_id: order.order_number,
|
||||
value: order.total,
|
||||
currency: order.currency,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const trackSearch = useCallback((query: string, results_count: number) => {
|
||||
op.track("search", {
|
||||
query,
|
||||
results_count,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const trackExternalLink = useCallback((url: string, label?: string) => {
|
||||
op.track("external_link_click", {
|
||||
url,
|
||||
label,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
const identifyUser = useCallback((user: {
|
||||
profileId: string;
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}) => {
|
||||
op.identify({
|
||||
profileId: user.profileId,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
properties: user.properties,
|
||||
});
|
||||
}, [op]);
|
||||
|
||||
return {
|
||||
trackProductView,
|
||||
trackAddToCart,
|
||||
trackRemoveFromCart,
|
||||
trackCheckoutStarted,
|
||||
trackCheckoutStep,
|
||||
trackOrderCompleted,
|
||||
trackSearch,
|
||||
trackExternalLink,
|
||||
identifyUser,
|
||||
};
|
||||
}
|
||||
|
||||
export default useAnalytics;
|
||||
106
src/lib/resend.ts
Normal file
106
src/lib/resend.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Resend } from "resend";
|
||||
import { render } from "@react-email/render";
|
||||
|
||||
let resendClient: Resend | null = null;
|
||||
|
||||
function getResendClient(): Resend {
|
||||
if (!resendClient) {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
throw new Error("RESEND_API_KEY environment variable is not set");
|
||||
}
|
||||
resendClient = new Resend(process.env.RESEND_API_KEY);
|
||||
}
|
||||
return resendClient;
|
||||
}
|
||||
|
||||
export const ADMIN_EMAILS = ["me@hytham.me", "tamara@hytham.me"];
|
||||
|
||||
export async function sendEmail({
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
text,
|
||||
tags,
|
||||
idempotencyKey,
|
||||
}: {
|
||||
to: string | string[];
|
||||
subject: string;
|
||||
react: React.ReactNode;
|
||||
text?: string;
|
||||
tags?: { name: string; value: string }[];
|
||||
idempotencyKey?: string;
|
||||
}) {
|
||||
const resend = getResendClient();
|
||||
|
||||
// Render React component to HTML
|
||||
const html = await render(react, {
|
||||
pretty: true,
|
||||
});
|
||||
|
||||
const { data, error } = await resend.emails.send({
|
||||
from: "ManoonOils <support@mail.manoonoils.com>",
|
||||
replyTo: "support@manoonoils.com",
|
||||
to: Array.isArray(to) ? to : [to],
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
tags,
|
||||
...(idempotencyKey && { idempotencyKey }),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to send email:", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function sendEmailToCustomer({
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
text,
|
||||
language,
|
||||
idempotencyKey,
|
||||
}: {
|
||||
to: string;
|
||||
subject: string;
|
||||
react: React.ReactNode;
|
||||
text?: string;
|
||||
language: string;
|
||||
idempotencyKey?: string;
|
||||
}) {
|
||||
const tag = `customer-${language}`;
|
||||
return sendEmail({
|
||||
to,
|
||||
subject,
|
||||
react,
|
||||
text,
|
||||
tags: [{ name: "type", value: tag }],
|
||||
idempotencyKey,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendEmailToAdmin({
|
||||
subject,
|
||||
react,
|
||||
text,
|
||||
eventType,
|
||||
orderId,
|
||||
}: {
|
||||
subject: string;
|
||||
react: React.ReactNode;
|
||||
text?: string;
|
||||
eventType: string;
|
||||
orderId: string;
|
||||
}) {
|
||||
return sendEmail({
|
||||
to: ADMIN_EMAILS,
|
||||
subject: `[Admin] ${subject}`,
|
||||
react,
|
||||
text,
|
||||
tags: [{ name: "type", value: "admin-notification" }],
|
||||
idempotencyKey: `admin-${eventType}/${orderId}`,
|
||||
});
|
||||
}
|
||||
81
src/lib/saleor/create-webhooks.graphql
Normal file
81
src/lib/saleor/create-webhooks.graphql
Normal file
@@ -0,0 +1,81 @@
|
||||
# Replace YOUR_STOREFRONT_URL with your actual storefront URL
|
||||
# Dev: https://dev.manoonoils.com
|
||||
# Prod: https://manoonoils.com
|
||||
|
||||
mutation CreateSaleorWebhooks {
|
||||
orderConfirmedWebhook: webhookCreate(input: {
|
||||
name: "Resend - Order Confirmed"
|
||||
targetUrl: "YOUR_STOREFRONT_URL/api/webhooks/saleor"
|
||||
events: [ORDER_CONFIRMED]
|
||||
isActive: true
|
||||
}) {
|
||||
webhook {
|
||||
id
|
||||
name
|
||||
targetUrl
|
||||
isActive
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
|
||||
orderPaidWebhook: webhookCreate(input: {
|
||||
name: "Resend - Order Paid"
|
||||
targetUrl: "YOUR_STOREFRONT_URL/api/webhooks/saleor"
|
||||
events: [ORDER_FULLY_PAID]
|
||||
isActive: true
|
||||
}) {
|
||||
webhook {
|
||||
id
|
||||
name
|
||||
targetUrl
|
||||
isActive
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
|
||||
orderCancelledWebhook: webhookCreate(input: {
|
||||
name: "Resend - Order Cancelled"
|
||||
targetUrl: "YOUR_STOREFRONT_URL/api/webhooks/saleor"
|
||||
events: [ORDER_CANCELLED]
|
||||
isActive: true
|
||||
}) {
|
||||
webhook {
|
||||
id
|
||||
name
|
||||
targetUrl
|
||||
isActive
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
|
||||
orderFulfilledWebhook: webhookCreate(input: {
|
||||
name: "Resend - Order Fulfilled"
|
||||
targetUrl: "YOUR_STOREFRONT_URL/api/webhooks/saleor"
|
||||
events: [ORDER_FULFILLED]
|
||||
isActive: true
|
||||
}) {
|
||||
webhook {
|
||||
id
|
||||
name
|
||||
targetUrl
|
||||
isActive
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,3 +152,24 @@ export const CHECKOUT_EMAIL_UPDATE = gql`
|
||||
}
|
||||
${CHECKOUT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CHECKOUT_METADATA_UPDATE = gql`
|
||||
mutation CheckoutMetadataUpdate($checkoutId: ID!, $metadata: [MetadataInput!]!) {
|
||||
updateMetadata(id: $checkoutId, input: $metadata) {
|
||||
item {
|
||||
... on Checkout {
|
||||
id
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
80
src/lib/services/AnalyticsService.ts
Normal file
80
src/lib/services/AnalyticsService.ts
Normal file
@@ -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;
|
||||
357
src/lib/services/OrderNotificationService.ts
Normal file
357
src/lib/services/OrderNotificationService.ts
Normal file
@@ -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;
|
||||
36
vitest.config.ts
Normal file
36
vitest.config.ts
Normal file
@@ -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