Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f9081cb52 | ||
|
|
7f35dc57c6 | ||
|
|
7d63f4fbcd | ||
|
|
b78b081d29 | ||
|
|
676dda4642 | ||
|
|
c8d184f9dc | ||
|
|
322c4c805b | ||
|
|
bcf74e1fd1 | ||
|
|
7ca756fc5a | ||
|
|
ca363a2406 | ||
|
|
5ec0e6c92c | ||
|
|
ee574cb736 | ||
|
|
a419337d99 | ||
|
|
09294fd752 | ||
|
|
f66f9b87ab |
@@ -0,0 +1,367 @@
|
|||||||
|
# One-Page Checkout Implementation Plan
|
||||||
|
|
||||||
|
**Branch:** `feature/one-page-checkout`
|
||||||
|
**Status:** In Development
|
||||||
|
**Priority:** High
|
||||||
|
**Phone Requirement:** Required (not optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Convert the current two-phase checkout into a streamlined one-page checkout experience where customers can see all fields at once and complete their order in a single action.
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
- **Phase 1:** Collect email, shipping address → fetch shipping methods
|
||||||
|
- **Phase 2:** Select shipping method, billing address → complete order
|
||||||
|
- **Total API calls:** 6-7 sequential requests across 2 user interactions
|
||||||
|
|
||||||
|
### Target State
|
||||||
|
- **Single Page:** All fields visible simultaneously
|
||||||
|
- **Dynamic updates:** Shipping methods fetch automatically when address changes
|
||||||
|
- **Single submit:** One "Complete Order" button
|
||||||
|
- **Optimized API:** 3-4 sequential steps (parallel where possible)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Must-Have
|
||||||
|
- [ ] All checkout fields visible on single page
|
||||||
|
- [ ] Phone number is **required** (strict validation)
|
||||||
|
- [ ] Shipping methods fetch automatically (debounced) when address changes
|
||||||
|
- [ ] Real-time total calculation (updates when shipping method selected)
|
||||||
|
- [ ] Single "Complete Order" submit button
|
||||||
|
- [ ] Section-based validation with inline errors
|
||||||
|
- [ ] Auto-scroll to first error on validation failure
|
||||||
|
- [ ] Preserve form data on error
|
||||||
|
|
||||||
|
### UX Requirements
|
||||||
|
- [ ] Clear visual hierarchy (Contact → Shipping → Billing → Shipping Method → Payment)
|
||||||
|
- [ ] Collapsible sections (optional - all expanded by default)
|
||||||
|
- [ ] Loading states for shipping method fetching
|
||||||
|
- [ ] Disabled submit button until all required fields valid
|
||||||
|
- [ ] Success confirmation page (existing)
|
||||||
|
|
||||||
|
### Technical Requirements
|
||||||
|
- [ ] Debounced shipping method API calls (500ms)
|
||||||
|
- [ ] Optimistic UI updates where possible
|
||||||
|
- [ ] Proper error handling per section
|
||||||
|
- [ ] Analytics events for checkout steps
|
||||||
|
- [ ] Mobile-responsive layout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Layout
|
||||||
|
|
||||||
|
### Left Column (Form - 60% width on desktop)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 1. Contact Information │
|
||||||
|
│ ├─ Email * [________________] │
|
||||||
|
│ └─ Phone * [________________] │
|
||||||
|
│ [+381... format hint] │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ 2. Shipping Address │
|
||||||
|
│ ├─ First Name * [____________] │
|
||||||
|
│ ├─ Last Name * [_____________] │
|
||||||
|
│ ├─ Country * [▼ Serbia ▼] │
|
||||||
|
│ ├─ Street Address * [________] │
|
||||||
|
│ ├─ Apt/Suite [______________] │
|
||||||
|
│ ├─ City * [_________________] │
|
||||||
|
│ └─ Postal Code * [__________] │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ 3. Billing Address │
|
||||||
|
│ [✓] Same as shipping address │
|
||||||
|
│ (Fields hidden when checked) │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ 4. Shipping Method │
|
||||||
|
│ (Loading... / Select to see │
|
||||||
|
│ available options) │
|
||||||
|
│ ○ Standard (2-3 days) 400 RSD │
|
||||||
|
│ ○ Express (1-2 days) 800 RSD │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ 5. Payment Method │
|
||||||
|
│ ● Cash on Delivery │
|
||||||
|
│ (Additional payment methods TBD) │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ [ Complete Order - 3,600 RSD ] │
|
||||||
|
│ Loading spinner when processing │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Right Column (Order Summary - 40% width on desktop)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Order Summary │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Product Image Serum x1 3,200 │
|
||||||
|
│ RSD │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Subtotal 3,200 RSD │
|
||||||
|
│ Shipping 400 RSD │
|
||||||
|
│ ───────────────────────────────── │
|
||||||
|
│ Total 3,600 RSD │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mobile Layout
|
||||||
|
Single column, stacked sections with sticky order summary at bottom.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Implementation
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Form state (existing)
|
||||||
|
const [shippingAddress, setShippingAddress] = useState<AddressForm>({...});
|
||||||
|
const [billingAddress, setBillingAddress] = useState<AddressForm>({...});
|
||||||
|
const [sameAsShipping, setSameAsShipping] = useState(true);
|
||||||
|
|
||||||
|
// New state
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState<string>("cod");
|
||||||
|
const [errors, setErrors] = useState<ValidationErrors>({
|
||||||
|
contact: null,
|
||||||
|
shipping: null,
|
||||||
|
billing: null,
|
||||||
|
shippingMethod: null,
|
||||||
|
general: null,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debounced Shipping Method Fetching
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAddressComplete(shippingAddress)) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fetchShippingMethods();
|
||||||
|
}, 500); // 500ms debounce
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [shippingAddress]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const validationRules = {
|
||||||
|
email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
|
||||||
|
phone: (value) => {
|
||||||
|
// Country-specific validation
|
||||||
|
// Serbia: +381 XX XXX XXXX
|
||||||
|
// Bosnia: +387 XX XXX XXX
|
||||||
|
// etc.
|
||||||
|
},
|
||||||
|
required: (value) => value.trim().length > 0,
|
||||||
|
postalCode: (value, country) => {
|
||||||
|
// Country-specific postal code validation
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Call Sequence
|
||||||
|
|
||||||
|
**Optimized Flow (parallel + sequential):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: Validation (client-side)
|
||||||
|
├─ Validate all fields
|
||||||
|
└─ Show inline errors
|
||||||
|
|
||||||
|
Step 2: Parallel Independent Calls
|
||||||
|
├─ Update Email
|
||||||
|
└─ Update Shipping Address
|
||||||
|
(Both can run simultaneously)
|
||||||
|
|
||||||
|
Step 3: Conditional Call
|
||||||
|
└─ Update Billing Address (if different from shipping)
|
||||||
|
|
||||||
|
Step 4: Sequential Dependent Calls
|
||||||
|
├─ Update Shipping Method
|
||||||
|
├─ Update Metadata (phone, language, payment method)
|
||||||
|
└─ Complete Checkout
|
||||||
|
|
||||||
|
Total: 4 sequential steps vs current 7+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling Strategy
|
||||||
|
|
||||||
|
**Field-level:**
|
||||||
|
- Real-time validation on blur
|
||||||
|
- Visual indicators (red border, error message)
|
||||||
|
- Prevent submit if validation fails
|
||||||
|
|
||||||
|
**Section-level:**
|
||||||
|
- Group errors by section
|
||||||
|
- Show section header in red if has errors
|
||||||
|
- Expand section if collapsed and has errors
|
||||||
|
|
||||||
|
**Form-level:**
|
||||||
|
- On submit: validate all fields
|
||||||
|
- If errors: scroll to first error, show summary
|
||||||
|
- If API error: show in relevant section, preserve data
|
||||||
|
|
||||||
|
**API-level:**
|
||||||
|
- Map Saleor errors to form fields when possible
|
||||||
|
- Generic error: show at top of form
|
||||||
|
- Network error: show retry button
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
### Primary Files
|
||||||
|
|
||||||
|
1. **`/src/app/[locale]/checkout/page.tsx`**
|
||||||
|
- Major refactor of checkout flow
|
||||||
|
- Combine Phase 1 & Phase 2 into single component
|
||||||
|
- Add debounced shipping method fetching
|
||||||
|
- Implement section-based validation
|
||||||
|
- Optimize API call sequence
|
||||||
|
|
||||||
|
2. **`/src/lib/saleor/mutations/Checkout.ts`**
|
||||||
|
- Ensure all mutations available
|
||||||
|
- Add metadata update mutation if needed
|
||||||
|
|
||||||
|
3. **`/src/lib/saleor/queries/Checkout.ts`**
|
||||||
|
- Ensure checkout query returns shipping methods
|
||||||
|
|
||||||
|
### Translation Files
|
||||||
|
|
||||||
|
4. **`/messages/sr.json`** (and other language files)
|
||||||
|
- Add new translation keys for one-page checkout
|
||||||
|
- Section headers
|
||||||
|
- Validation messages
|
||||||
|
- Button labels
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
5. **`/src/app/globals.css`** (or Tailwind config)
|
||||||
|
- Ensure consistent form styling
|
||||||
|
- Add validation state styles
|
||||||
|
- Loading spinner styles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Core Structure (Day 1-2)
|
||||||
|
- [ ] Refactor checkout page layout
|
||||||
|
- [ ] Display all sections simultaneously
|
||||||
|
- [ ] Keep existing form logic working
|
||||||
|
- [ ] Test existing flow still works
|
||||||
|
|
||||||
|
### Phase 2: Dynamic Shipping Methods (Day 3)
|
||||||
|
- [ ] Implement debounced fetching
|
||||||
|
- [ ] Add loading states
|
||||||
|
- [ ] Display shipping methods inline
|
||||||
|
- [ ] Update total when method selected
|
||||||
|
|
||||||
|
### Phase 3: Validation & Error Handling (Day 4)
|
||||||
|
- [ ] Implement field-level validation
|
||||||
|
- [ ] Add section-based error display
|
||||||
|
- [ ] Auto-scroll to errors
|
||||||
|
- [ ] Test all validation scenarios
|
||||||
|
|
||||||
|
### Phase 4: Optimization (Day 5)
|
||||||
|
- [ ] Optimize API call sequence
|
||||||
|
- [ ] Add parallel mutation execution
|
||||||
|
- [ ] Improve loading states
|
||||||
|
- [ ] Add optimistic updates
|
||||||
|
|
||||||
|
### Phase 5: Polish (Day 6)
|
||||||
|
- [ ] Mobile responsiveness
|
||||||
|
- [ ] Analytics events
|
||||||
|
- [ ] Accessibility improvements
|
||||||
|
- [ ] Final testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Functionality Tests
|
||||||
|
- [ ] Fill all fields, submit successfully
|
||||||
|
- [ ] Verify order created in Saleor
|
||||||
|
- [ ] Verify emails sent
|
||||||
|
- [ ] Change shipping method, verify total updates
|
||||||
|
- [ ] Change address, verify shipping methods refetch
|
||||||
|
|
||||||
|
### Validation Tests
|
||||||
|
- [ ] Submit with empty email → email error
|
||||||
|
- [ ] Submit with empty phone → phone error
|
||||||
|
- [ ] Submit with invalid email format → format error
|
||||||
|
- [ ] Submit with invalid phone → format error
|
||||||
|
- [ ] Submit with empty required fields → field errors
|
||||||
|
- [ ] Submit without selecting shipping method → shipping error
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- [ ] Slow network (test debouncing)
|
||||||
|
- [ ] No shipping methods available
|
||||||
|
- [ ] API failure during submission
|
||||||
|
- [ ] Partial API failure (some mutations succeed)
|
||||||
|
- [ ] Browser refresh (preserve data?)
|
||||||
|
|
||||||
|
### Mobile Tests
|
||||||
|
- [ ] Layout works on iPhone SE
|
||||||
|
- [ ] Layout works on iPhone 14 Pro Max
|
||||||
|
- [ ] Touch targets large enough
|
||||||
|
- [ ] Scroll behavior smooth
|
||||||
|
|
||||||
|
### Accessibility Tests
|
||||||
|
- [ ] Tab navigation works
|
||||||
|
- [ ] Screen reader friendly
|
||||||
|
- [ ] Error announcements
|
||||||
|
- [ ] Focus management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollout Strategy
|
||||||
|
|
||||||
|
1. **Development:** Complete on feature branch
|
||||||
|
2. **Testing:** Local testing with all scenarios
|
||||||
|
3. **Staging:** Deploy to dev.manoonoils.com
|
||||||
|
4. **Monitoring:** Check for errors, conversion rates
|
||||||
|
5. **Production:** Merge to master and deploy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
- **Conversion Rate:** Should increase (fewer steps = less drop-off)
|
||||||
|
- **Time to Complete:** Should decrease (single page vs two phases)
|
||||||
|
- **Error Rate:** Should decrease (better validation)
|
||||||
|
- **Mobile Completion:** Should improve (optimized for mobile)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements (Out of Scope)
|
||||||
|
|
||||||
|
- [ ] Save addresses for logged-in users
|
||||||
|
- [ ] Address autocomplete (Google Maps)
|
||||||
|
- [ ] Multiple payment methods (Stripe, etc.)
|
||||||
|
- [ ] Guest checkout improvements
|
||||||
|
- [ ] Order notes/comments field
|
||||||
|
- [ ] Gift wrapping options
|
||||||
|
- [ ] Promo code input
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Phone number is **strictly required** - validate format per country
|
||||||
|
- Keep existing checkout success page
|
||||||
|
- Maintain multi-language support
|
||||||
|
- Ensure analytics tracking works
|
||||||
|
- Don't break existing cart functionality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Created:** March 28, 2026
|
||||||
|
**Branch:** feature/one-page-checkout
|
||||||
|
**Next Step:** Start Phase 1 - Core Structure
|
||||||
@@ -129,11 +129,78 @@ export default function CheckoutPage() {
|
|||||||
|
|
||||||
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
|
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
|
||||||
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
|
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
|
||||||
const [showShippingMethods, setShowShippingMethods] = useState(false);
|
const [isLoadingShipping, setIsLoadingShipping] = useState(false);
|
||||||
|
|
||||||
const lines = getLines();
|
const lines = getLines();
|
||||||
const total = getTotal();
|
const total = getTotal();
|
||||||
|
|
||||||
|
// Debounced shipping method fetching
|
||||||
|
useEffect(() => {
|
||||||
|
if (!checkout) return;
|
||||||
|
|
||||||
|
// Check if address is complete enough to fetch shipping methods
|
||||||
|
const isAddressComplete =
|
||||||
|
shippingAddress.firstName &&
|
||||||
|
shippingAddress.lastName &&
|
||||||
|
shippingAddress.streetAddress1 &&
|
||||||
|
shippingAddress.city &&
|
||||||
|
shippingAddress.postalCode &&
|
||||||
|
shippingAddress.country;
|
||||||
|
|
||||||
|
if (!isAddressComplete) {
|
||||||
|
setShippingMethods([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setIsLoadingShipping(true);
|
||||||
|
try {
|
||||||
|
console.log("Fetching shipping methods...");
|
||||||
|
|
||||||
|
// First update the shipping address
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then query for 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);
|
||||||
|
|
||||||
|
setShippingMethods(availableMethods);
|
||||||
|
|
||||||
|
// Auto-select first method if none selected
|
||||||
|
if (availableMethods.length > 0 && !selectedShippingMethod) {
|
||||||
|
setSelectedShippingMethod(availableMethods[0].id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching shipping methods:", err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingShipping(false);
|
||||||
|
}
|
||||||
|
}, 500); // 500ms debounce
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [checkout, shippingAddress]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!checkout) {
|
if (!checkout) {
|
||||||
refreshCheckout();
|
refreshCheckout();
|
||||||
@@ -189,25 +256,56 @@ export default function CheckoutPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate all required fields
|
||||||
if (!shippingAddress.email || !shippingAddress.email.includes("@")) {
|
if (!shippingAddress.email || !shippingAddress.email.includes("@")) {
|
||||||
setError(t("errorEmailRequired"));
|
setError(t("errorEmailRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shippingAddress.firstName || !shippingAddress.lastName || !shippingAddress.streetAddress1 || !shippingAddress.city || !shippingAddress.postalCode || !shippingAddress.phone) {
|
if (!shippingAddress.phone || shippingAddress.phone.length < 8) {
|
||||||
|
setError(t("errorPhoneRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shippingAddress.firstName || !shippingAddress.lastName || !shippingAddress.streetAddress1 || !shippingAddress.city || !shippingAddress.postalCode) {
|
||||||
setError(t("errorFieldsRequired"));
|
setError(t("errorFieldsRequired"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!selectedShippingMethod) {
|
||||||
|
setError(t("errorSelectShipping"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// If we're showing shipping methods and one is selected, complete the order
|
console.log("Completing order...");
|
||||||
if (showShippingMethods && selectedShippingMethod) {
|
|
||||||
console.log("Phase 2: Completing order with shipping method...");
|
|
||||||
|
|
||||||
console.log("Step 1: Updating billing 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) {
|
||||||
|
const errorMessage = emailResult.data.checkoutEmailUpdate.errors[0].message;
|
||||||
|
if (errorMessage.includes("Couldn't resolve to a node")) {
|
||||||
|
console.error("Checkout not found, clearing cart...");
|
||||||
|
localStorage.removeItem('cart');
|
||||||
|
localStorage.removeItem('checkoutId');
|
||||||
|
window.location.href = `/${locale}/products`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`Email update failed: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
console.log("Step 1: Email updated successfully");
|
||||||
|
|
||||||
|
console.log("Step 2: Updating billing address...");
|
||||||
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
||||||
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||||
variables: {
|
variables: {
|
||||||
@@ -228,9 +326,9 @@ export default function CheckoutPage() {
|
|||||||
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
|
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
|
||||||
throw new Error(`Billing address update failed: ${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: Billing address updated successfully");
|
||||||
|
|
||||||
console.log("Step 2: Setting shipping method...");
|
console.log("Step 3: Setting shipping method...");
|
||||||
const shippingMethodResult = await saleorClient.mutate<ShippingMethodUpdateResponse>({
|
const shippingMethodResult = await saleorClient.mutate<ShippingMethodUpdateResponse>({
|
||||||
mutation: CHECKOUT_SHIPPING_METHOD_UPDATE,
|
mutation: CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||||
variables: {
|
variables: {
|
||||||
@@ -242,9 +340,9 @@ export default function CheckoutPage() {
|
|||||||
if (shippingMethodResult.data?.checkoutShippingMethodUpdate?.errors && shippingMethodResult.data.checkoutShippingMethodUpdate.errors.length > 0) {
|
if (shippingMethodResult.data?.checkoutShippingMethodUpdate?.errors && shippingMethodResult.data.checkoutShippingMethodUpdate.errors.length > 0) {
|
||||||
throw new Error(`Shipping method update failed: ${shippingMethodResult.data.checkoutShippingMethodUpdate.errors[0].message}`);
|
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: Shipping method set successfully");
|
||||||
|
|
||||||
console.log("Step 3: Saving phone number...");
|
console.log("Step 4: Saving metadata...");
|
||||||
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
|
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
|
||||||
mutation: CHECKOUT_METADATA_UPDATE,
|
mutation: CHECKOUT_METADATA_UPDATE,
|
||||||
variables: {
|
variables: {
|
||||||
@@ -252,6 +350,8 @@ export default function CheckoutPage() {
|
|||||||
metadata: [
|
metadata: [
|
||||||
{ key: "phone", value: shippingAddress.phone },
|
{ key: "phone", value: shippingAddress.phone },
|
||||||
{ key: "shippingPhone", value: shippingAddress.phone },
|
{ key: "shippingPhone", value: shippingAddress.phone },
|
||||||
|
{ key: "userLanguage", value: locale },
|
||||||
|
{ key: "userLocale", value: locale },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -259,10 +359,10 @@ export default function CheckoutPage() {
|
|||||||
if (metadataResult.data?.updateMetadata?.errors && metadataResult.data.updateMetadata.errors.length > 0) {
|
if (metadataResult.data?.updateMetadata?.errors && metadataResult.data.updateMetadata.errors.length > 0) {
|
||||||
console.warn("Failed to save phone metadata:", metadataResult.data.updateMetadata.errors);
|
console.warn("Failed to save phone metadata:", metadataResult.data.updateMetadata.errors);
|
||||||
} else {
|
} else {
|
||||||
console.log("Step 3: Phone number saved successfully");
|
console.log("Step 4: Phone number saved successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Step 4: Completing checkout...");
|
console.log("Step 5: Completing checkout...");
|
||||||
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
|
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
|
||||||
mutation: CHECKOUT_COMPLETE,
|
mutation: CHECKOUT_COMPLETE,
|
||||||
variables: {
|
variables: {
|
||||||
@@ -302,84 +402,6 @@ export default function CheckoutPage() {
|
|||||||
} else {
|
} else {
|
||||||
throw new Error(t("errorCreatingOrder"));
|
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) {
|
} catch (err: unknown) {
|
||||||
console.error("Checkout error:", err);
|
console.error("Checkout error:", err);
|
||||||
|
|
||||||
@@ -595,9 +617,17 @@ export default function CheckoutPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Shipping Method Selection */}
|
{/* Shipping Method Selection */}
|
||||||
{showShippingMethods && shippingMethods.length > 0 && (
|
|
||||||
<div className="border-b border-border pb-6">
|
<div className="border-b border-border pb-6">
|
||||||
<h2 className="text-xl font-serif mb-4">{t("shippingMethod")}</h2>
|
<h2 className="text-xl font-serif mb-4">{t("shippingMethod")}</h2>
|
||||||
|
{isLoadingShipping ? (
|
||||||
|
<div className="flex items-center gap-2 text-foreground-muted">
|
||||||
|
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{t("loadingShippingMethods")}</span>
|
||||||
|
</div>
|
||||||
|
) : shippingMethods.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{shippingMethods.map((method) => (
|
{shippingMethods.map((method) => (
|
||||||
<label
|
<label
|
||||||
@@ -625,18 +655,17 @@ export default function CheckoutPage() {
|
|||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!selectedShippingMethod && (
|
) : (
|
||||||
<p className="text-red-500 text-sm mt-2">{t("errorSelectShipping")}</p>
|
<p className="text-foreground-muted">{t("enterAddressForShipping")}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || lines.length === 0 || (showShippingMethods && !selectedShippingMethod)}
|
disabled={isLoading || lines.length === 0 || !selectedShippingMethod}
|
||||||
className="w-full py-4 bg-foreground text-white font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
|
className="w-full py-4 bg-foreground text-white font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isLoading ? t("processing") : showShippingMethods ? t("completeOrder", { total: formatPrice(total) }) : t("continueToShipping")}
|
{isLoading ? t("processing") : t("completeOrder", { total: formatPrice(total) })}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -680,6 +709,12 @@ export default function CheckoutPage() {
|
|||||||
<span className="text-foreground-muted">{t("subtotal")}</span>
|
<span className="text-foreground-muted">{t("subtotal")}</span>
|
||||||
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
|
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedShippingMethod && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-foreground-muted">{t("shipping")}</span>
|
||||||
|
<span>{formatPrice(shippingMethods.find(m => m.id === selectedShippingMethod)?.price.amount || 0)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex justify-between font-medium text-lg pt-2 border-t border-border">
|
<div className="flex justify-between font-medium text-lg pt-2 border-t border-border">
|
||||||
<span>{t("total")}</span>
|
<span>{t("total")}</span>
|
||||||
<span>{formatPrice(total)}</span>
|
<span>{formatPrice(total)}</span>
|
||||||
|
|||||||
@@ -1,295 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export function formatPrice(amount: number, currency: string): string {
|
|
||||||
return new Intl.NumberFormat("sr-RS", {
|
|
||||||
style: "currency",
|
|
||||||
currency: currency,
|
|
||||||
}).format(amount);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -30,14 +30,16 @@ export default function CartDrawer() {
|
|||||||
const lines = getLines();
|
const lines = getLines();
|
||||||
const total = getTotal();
|
const total = getTotal();
|
||||||
const lineCount = getLineCount();
|
const lineCount = getLineCount();
|
||||||
const [initialized, setInitialized] = useState(false);
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized) {
|
if (!initializedRef.current && locale) {
|
||||||
|
// Set language code before initializing checkout
|
||||||
|
useSaleorCheckoutStore.getState().setLanguageCode(locale);
|
||||||
initCheckout();
|
initCheckout();
|
||||||
setInitialized(true);
|
initializedRef.current = true;
|
||||||
}
|
}
|
||||||
}, [initialized]);
|
}, [locale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ interface NewHeroProps {
|
|||||||
|
|
||||||
export default function NewHero({ featuredProduct }: NewHeroProps) {
|
export default function NewHero({ featuredProduct }: NewHeroProps) {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { addLine, openCart } = useSaleorCheckoutStore();
|
const { addLine, openCart, setLanguageCode } = useSaleorCheckoutStore();
|
||||||
|
|
||||||
const handleAddToCart = async () => {
|
const handleAddToCart = async () => {
|
||||||
|
// Set language code before adding to cart
|
||||||
|
if (locale) {
|
||||||
|
setLanguageCode(locale);
|
||||||
|
}
|
||||||
const variant = featuredProduct?.variants?.[0];
|
const variant = featuredProduct?.variants?.[0];
|
||||||
if (variant?.id) {
|
if (variant?.id) {
|
||||||
await addLine(variant.id, 1);
|
await addLine(variant.id, 1);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||||
import { User, ShoppingBag, Menu, X, Globe } from "lucide-react";
|
import { User, ShoppingBag, Menu, X, Globe } from "lucide-react";
|
||||||
import CartDrawer from "@/components/cart/CartDrawer";
|
import CartDrawer from "@/components/cart/CartDrawer";
|
||||||
@@ -16,14 +16,15 @@ interface HeaderProps {
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({ locale = "sr" }: HeaderProps) {
|
export default function Header({ locale: propLocale = "sr" }: HeaderProps) {
|
||||||
const t = useTranslations("Header");
|
const t = useTranslations("Header");
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [langDropdownOpen, setLangDropdownOpen] = useState(false);
|
const [langDropdownOpen, setLangDropdownOpen] = useState(false);
|
||||||
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
|
const { getLineCount, toggleCart, initCheckout, setLanguageCode } = useSaleorCheckoutStore();
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
const itemCount = getLineCount();
|
const itemCount = getLineCount();
|
||||||
const currentLocale = isValidLocale(locale) ? LOCALE_CONFIG[locale] : LOCALE_CONFIG.sr;
|
const currentLocale = isValidLocale(locale) ? LOCALE_CONFIG[locale] : LOCALE_CONFIG.sr;
|
||||||
@@ -54,9 +55,14 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
|||||||
setLangDropdownOpen(false);
|
setLangDropdownOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set language code first, then initialize checkout
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (locale) {
|
||||||
|
setLanguageCode(locale);
|
||||||
|
// Initialize checkout after language code is set
|
||||||
initCheckout();
|
initCheckout();
|
||||||
}, [initCheckout]);
|
}
|
||||||
|
}, [locale, setLanguageCode, initCheckout]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
|
|||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [urgencyIndex, setUrgencyIndex] = useState(0);
|
const [urgencyIndex, setUrgencyIndex] = useState(0);
|
||||||
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
|
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
|
||||||
const { addLine, openCart } = useSaleorCheckoutStore();
|
const { addLine, openCart, setLanguageCode } = useSaleorCheckoutStore();
|
||||||
const { trackProductView, trackAddToCart } = useAnalytics();
|
const { trackProductView, trackAddToCart } = useAnalytics();
|
||||||
const validLocale = isValidLocale(locale) ? locale : "sr";
|
const validLocale = isValidLocale(locale) ? locale : "sr";
|
||||||
|
|
||||||
@@ -147,6 +147,11 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
|
|||||||
const handleAddToCart = async () => {
|
const handleAddToCart = async () => {
|
||||||
if (!selectedVariantId) return;
|
if (!selectedVariantId) return;
|
||||||
|
|
||||||
|
// Set language code before adding to cart
|
||||||
|
if (validLocale) {
|
||||||
|
setLanguageCode(validLocale);
|
||||||
|
}
|
||||||
|
|
||||||
setIsAdding(true);
|
setIsAdding(true);
|
||||||
try {
|
try {
|
||||||
await addLine(selectedVariantId, 1);
|
await addLine(selectedVariantId, 1);
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
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 [email protected]",
|
|
||||||
},
|
|
||||||
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 [email protected]",
|
|
||||||
},
|
|
||||||
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 [email protected]",
|
|
||||||
},
|
|
||||||
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 à [email protected]",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
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 [email protected]",
|
|
||||||
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 [email protected]",
|
|
||||||
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 [email protected]",
|
|
||||||
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 à [email protected]",
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
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 [email protected]",
|
|
||||||
},
|
|
||||||
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 [email protected]",
|
|
||||||
},
|
|
||||||
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 [email protected]",
|
|
||||||
},
|
|
||||||
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 à [email protected]",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
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 [email protected]",
|
|
||||||
},
|
|
||||||
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 [email protected]",
|
|
||||||
},
|
|
||||||
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 [email protected]",
|
|
||||||
},
|
|
||||||
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 à [email protected]",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export { BaseLayout } from "./BaseLayout";
|
|
||||||
export { OrderConfirmation } from "./OrderConfirmation";
|
|
||||||
export { OrderShipped } from "./OrderShipped";
|
|
||||||
export { OrderCancelled } from "./OrderCancelled";
|
|
||||||
export { OrderPaid } from "./OrderPaid";
|
|
||||||
@@ -372,6 +372,11 @@
|
|||||||
"errorNoCheckout": "Keine aktive Kasse. Bitte versuchen Sie es erneut.",
|
"errorNoCheckout": "Keine aktive Kasse. Bitte versuchen Sie es erneut.",
|
||||||
"errorEmailRequired": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
|
"errorEmailRequired": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
|
||||||
"errorFieldsRequired": "Bitte füllen Sie alle erforderlichen Felder aus.",
|
"errorFieldsRequired": "Bitte füllen Sie alle erforderlichen Felder aus.",
|
||||||
|
"errorNoShippingMethods": "Keine Versandmethoden für diese Adresse verfügbar. Bitte überprüfen Sie Ihre Adresse oder kontaktieren Sie den Support.",
|
||||||
|
"errorSelectShipping": "Bitte wählen Sie eine Versandmethode.",
|
||||||
|
"errorPhoneRequired": "Bitte geben Sie eine gültige Telefonnummer ein.",
|
||||||
|
"loadingShippingMethods": "Versandoptionen werden geladen...",
|
||||||
|
"enterAddressForShipping": "Geben Sie Ihre Adresse ein, um Versandoptionen zu sehen.",
|
||||||
"errorOccurred": "Ein Fehler ist during des Checkouts aufgetreten.",
|
"errorOccurred": "Ein Fehler ist during des Checkouts aufgetreten.",
|
||||||
"errorCreatingOrder": "Bestellung konnte nicht erstellt werden.",
|
"errorCreatingOrder": "Bestellung konnte nicht erstellt werden.",
|
||||||
"orderConfirmed": "Bestellung bestätigt!",
|
"orderConfirmed": "Bestellung bestätigt!",
|
||||||
|
|||||||
@@ -420,6 +420,9 @@
|
|||||||
"errorFieldsRequired": "Please fill in all required fields.",
|
"errorFieldsRequired": "Please fill in all required fields.",
|
||||||
"errorNoShippingMethods": "No shipping methods available for this address. Please check your address or contact support.",
|
"errorNoShippingMethods": "No shipping methods available for this address. Please check your address or contact support.",
|
||||||
"errorSelectShipping": "Please select a shipping method.",
|
"errorSelectShipping": "Please select a shipping method.",
|
||||||
|
"errorPhoneRequired": "Please enter a valid phone number.",
|
||||||
|
"loadingShippingMethods": "Loading shipping options...",
|
||||||
|
"enterAddressForShipping": "Enter your address to see shipping options.",
|
||||||
"errorOccurred": "An error occurred during checkout.",
|
"errorOccurred": "An error occurred during checkout.",
|
||||||
"errorCreatingOrder": "Failed to create order.",
|
"errorCreatingOrder": "Failed to create order.",
|
||||||
"continueToShipping": "Continue to Shipping",
|
"continueToShipping": "Continue to Shipping",
|
||||||
|
|||||||
@@ -372,6 +372,11 @@
|
|||||||
"errorNoCheckout": "Pas de paiement actif. Veuillez réessayer.",
|
"errorNoCheckout": "Pas de paiement actif. Veuillez réessayer.",
|
||||||
"errorEmailRequired": "Veuillez entrer une adresse e-mail valide.",
|
"errorEmailRequired": "Veuillez entrer une adresse e-mail valide.",
|
||||||
"errorFieldsRequired": "Veuillez remplir tous les champs obligatoires.",
|
"errorFieldsRequired": "Veuillez remplir tous les champs obligatoires.",
|
||||||
|
"errorNoShippingMethods": "Aucune méthode de livraison disponible pour cette adresse. Veuillez vérifier votre adresse ou contacter le support.",
|
||||||
|
"errorSelectShipping": "Veuillez sélectionner une méthode de livraison.",
|
||||||
|
"errorPhoneRequired": "Veuillez entrer un numéro de téléphone valide.",
|
||||||
|
"loadingShippingMethods": "Chargement des options de livraison...",
|
||||||
|
"enterAddressForShipping": "Entrez votre adresse pour voir les options de livraison.",
|
||||||
"errorOccurred": "Une erreur s'est produite lors du paiement.",
|
"errorOccurred": "Une erreur s'est produite lors du paiement.",
|
||||||
"errorCreatingOrder": "Échec de la création de la commande.",
|
"errorCreatingOrder": "Échec de la création de la commande.",
|
||||||
"orderConfirmed": "Commande Confirmée!",
|
"orderConfirmed": "Commande Confirmée!",
|
||||||
|
|||||||
@@ -418,6 +418,11 @@
|
|||||||
"errorNoCheckout": "Nema aktivne korpe. Molimo pokušajte ponovo.",
|
"errorNoCheckout": "Nema aktivne korpe. Molimo pokušajte ponovo.",
|
||||||
"errorEmailRequired": "Molimo unesite validnu email adresu.",
|
"errorEmailRequired": "Molimo unesite validnu email adresu.",
|
||||||
"errorFieldsRequired": "Molimo popunite sva obavezna polja.",
|
"errorFieldsRequired": "Molimo popunite sva obavezna polja.",
|
||||||
|
"errorNoShippingMethods": "Nema dostupnih načina dostave za ovu adresu. Molimo proverite adresu ili kontaktirajte podršku.",
|
||||||
|
"errorSelectShipping": "Molimo izaberite način dostave.",
|
||||||
|
"errorPhoneRequired": "Molimo unesite validan broj telefona.",
|
||||||
|
"loadingShippingMethods": "Učitavanje opcija dostave...",
|
||||||
|
"enterAddressForShipping": "Unesite adresu da vidite opcije dostave.",
|
||||||
"errorOccurred": "Došlo je do greške prilikom kupovine.",
|
"errorOccurred": "Došlo je do greške prilikom kupovine.",
|
||||||
"errorCreatingOrder": "Neuspešno kreiranje narudžbine.",
|
"errorCreatingOrder": "Neuspešno kreiranje narudžbine.",
|
||||||
"orderConfirmed": "Narudžbina potvrđena!",
|
"orderConfirmed": "Narudžbina potvrđena!",
|
||||||
|
|||||||
@@ -1,357 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -58,6 +58,7 @@ interface GetCheckoutResponse {
|
|||||||
interface SaleorCheckoutStore {
|
interface SaleorCheckoutStore {
|
||||||
checkout: Checkout | null;
|
checkout: Checkout | null;
|
||||||
checkoutToken: string | null;
|
checkoutToken: string | null;
|
||||||
|
languageCode: string | null;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
@@ -68,6 +69,7 @@ interface SaleorCheckoutStore {
|
|||||||
updateLine: (lineId: string, quantity: number) => Promise<void>;
|
updateLine: (lineId: string, quantity: number) => Promise<void>;
|
||||||
removeLine: (lineId: string) => Promise<void>;
|
removeLine: (lineId: string) => Promise<void>;
|
||||||
setEmail: (email: string) => Promise<void>;
|
setEmail: (email: string) => Promise<void>;
|
||||||
|
setLanguageCode: (languageCode: string) => void;
|
||||||
refreshCheckout: () => Promise<void>;
|
refreshCheckout: () => Promise<void>;
|
||||||
toggleCart: () => void;
|
toggleCart: () => void;
|
||||||
openCart: () => void;
|
openCart: () => void;
|
||||||
@@ -85,12 +87,13 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
checkout: null,
|
checkout: null,
|
||||||
checkoutToken: null,
|
checkoutToken: null,
|
||||||
|
languageCode: null,
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
initCheckout: async () => {
|
initCheckout: async () => {
|
||||||
const { checkoutToken } = get();
|
const { checkoutToken, languageCode } = get();
|
||||||
|
|
||||||
if (checkoutToken) {
|
if (checkoutToken) {
|
||||||
// Try to fetch existing checkout
|
// Try to fetch existing checkout
|
||||||
@@ -109,7 +112,7 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new checkout
|
// Create new checkout with language code
|
||||||
try {
|
try {
|
||||||
const { data } = await saleorClient.mutate<CheckoutCreateResponse>({
|
const { data } = await saleorClient.mutate<CheckoutCreateResponse>({
|
||||||
mutation: CHECKOUT_CREATE,
|
mutation: CHECKOUT_CREATE,
|
||||||
@@ -117,6 +120,7 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
|||||||
input: {
|
input: {
|
||||||
channel: CHANNEL,
|
channel: CHANNEL,
|
||||||
lines: [],
|
lines: [],
|
||||||
|
languageCode: languageCode ? languageCode.toUpperCase() : undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -294,6 +298,7 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
|||||||
openCart: () => set({ isOpen: true }),
|
openCart: () => set({ isOpen: true }),
|
||||||
closeCart: () => set({ isOpen: false }),
|
closeCart: () => set({ isOpen: false }),
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
|
setLanguageCode: (languageCode: string) => set({ languageCode }),
|
||||||
|
|
||||||
getLineCount: () => {
|
getLineCount: () => {
|
||||||
const { checkout } = get();
|
const { checkout } = get();
|
||||||
|
|||||||
Reference in New Issue
Block a user