Compare commits
26 Commits
test/suite
...
feature/ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c3d8b0d11 | ||
|
|
e15e6470d2 | ||
|
|
5f9b7bac3a | ||
|
|
fbe0761609 | ||
|
|
10b18c6010 | ||
|
|
eaf599f248 | ||
|
|
82c23e37a1 | ||
|
|
3e7ac79cf4 | ||
|
|
0a87cdc347 | ||
|
|
ff481f18c3 | ||
|
|
6f9081cb52 | ||
|
|
7f35dc57c6 | ||
|
|
7d63f4fbcd | ||
|
|
b78b081d29 | ||
|
|
676dda4642 | ||
|
|
c8d184f9dc | ||
|
|
322c4c805b | ||
|
|
bcf74e1fd1 | ||
|
|
7ca756fc5a | ||
|
|
ca363a2406 | ||
|
|
5ec0e6c92c | ||
|
|
ee574cb736 | ||
|
|
a419337d99 | ||
|
|
09294fd752 | ||
|
|
a6ebcf408c | ||
|
|
f66f9b87ab |
367
ONE-PAGE-CHECKOUT-PLAN.md
Normal file
367
ONE-PAGE-CHECKOUT-PLAN.md
Normal file
@@ -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
|
||||
317
docs/CHECKOUT_ARCHITECTURE_ANALYSIS.md
Normal file
317
docs/CHECKOUT_ARCHITECTURE_ANALYSIS.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Checkout Architecture Analysis
|
||||
|
||||
## What Broke: Root Cause Analysis
|
||||
|
||||
### The Incident
|
||||
Yesterday, checkout confirmation emails were working correctly in the customer's selected language. Today, they started arriving in English regardless of the customer's language preference.
|
||||
|
||||
### Root Cause
|
||||
**Implicit Dependency on Step Ordering**
|
||||
|
||||
The checkout flow had a critical implicit requirement: the `languageCode` field MUST be set on the checkout object BEFORE calling `checkoutComplete`. This was discovered through trial and error, not through explicit architecture.
|
||||
|
||||
### Why Small Changes Broke It
|
||||
|
||||
The checkout flow was implemented as a **procedural monolith** in `page.tsx`:
|
||||
|
||||
```typescript
|
||||
// ❌ BEFORE: Monolithic function (440+ lines)
|
||||
const handleSubmit = async () => {
|
||||
// Step 1: Email
|
||||
await updateEmail()
|
||||
|
||||
// Step 2: Language ← This was added today
|
||||
await updateLanguage() // <- Without this, emails are in wrong language!
|
||||
|
||||
// Step 3: Addresses
|
||||
await updateBillingAddress()
|
||||
|
||||
// Step 4: Shipping
|
||||
await updateShippingMethod()
|
||||
|
||||
// Step 5: Metadata
|
||||
await updateMetadata()
|
||||
|
||||
// Step 6: Complete
|
||||
await checkoutComplete()
|
||||
}
|
||||
```
|
||||
|
||||
**Problems with this approach:**
|
||||
|
||||
1. **No explicit contracts**: Nothing says "language must be set before complete"
|
||||
2. **Ordering is fragile**: Moving steps around breaks functionality
|
||||
3. **No isolation**: Can't test individual steps
|
||||
4. **Tight coupling**: UI, validation, API calls, and business logic all mixed
|
||||
5. **No failure boundaries**: One failure stops everything, but unclear where
|
||||
|
||||
## The Fix: Proper Abstraction
|
||||
|
||||
### New Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ UI Layer (Page Component) │
|
||||
│ - Form handling │
|
||||
│ - Display logic │
|
||||
│ - Error display │
|
||||
└───────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Checkout Service Layer │
|
||||
│ - executeCheckoutPipeline() │
|
||||
│ - Enforces step ordering │
|
||||
│ - Validates inputs │
|
||||
│ - Handles failures │
|
||||
└───────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Individual Steps (Composable) │
|
||||
│ - updateCheckoutEmail() │
|
||||
│ - updateCheckoutLanguage() ← CRITICAL: Before complete! │
|
||||
│ - updateShippingAddress() │
|
||||
│ - updateBillingAddress() │
|
||||
│ - updateShippingMethod() │
|
||||
│ - updateCheckoutMetadata() │
|
||||
│ - completeCheckout() │
|
||||
└───────────────────────┬─────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Saleor API Client │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Key Improvements
|
||||
|
||||
#### 1. **Explicit Pipeline**
|
||||
```typescript
|
||||
// ✅ AFTER: Explicit pipeline with enforced ordering
|
||||
export async function executeCheckoutPipeline(input: CheckoutInput) {
|
||||
// Step 1: Email
|
||||
const emailResult = await updateCheckoutEmail(checkoutId, email);
|
||||
if (!emailResult.success) return { success: false, error: emailResult.error };
|
||||
|
||||
// Step 2: Language (CRITICAL for email language)
|
||||
const languageResult = await updateCheckoutLanguage(checkoutId, languageCode);
|
||||
if (!languageResult.success) return { success: false, error: languageResult.error };
|
||||
// ^^^ This MUST happen before complete - enforced by structure!
|
||||
|
||||
// Step 3: Addresses
|
||||
// ...
|
||||
|
||||
// Step 7: Complete
|
||||
return completeCheckout(checkoutId);
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Order is enforced by code structure, not comments
|
||||
- Each step validates its result before continuing
|
||||
- Clear failure points
|
||||
|
||||
#### 2. **Composable Steps**
|
||||
Each step is an independent, testable function:
|
||||
|
||||
```typescript
|
||||
// Can be tested in isolation
|
||||
export async function updateCheckoutLanguage(
|
||||
checkoutId: string,
|
||||
languageCode: string
|
||||
): Promise<CheckoutStepResult> {
|
||||
const { data } = await saleorClient.mutate({
|
||||
mutation: CHECKOUT_LANGUAGE_CODE_UPDATE,
|
||||
variables: { checkoutId, languageCode },
|
||||
});
|
||||
|
||||
if (data?.checkoutLanguageCodeUpdate?.errors?.length) {
|
||||
return { success: false, error: data.checkoutLanguageCodeUpdate.errors[0].message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Unit testable
|
||||
- Can be reused in other flows
|
||||
- Can be mocked for testing
|
||||
- Clear input/output contracts
|
||||
|
||||
#### 3. **Validation Separation**
|
||||
```typescript
|
||||
// Pure validation functions
|
||||
export function validateAddress(address: Partial<Address>): string | null {
|
||||
if (!address.firstName?.trim()) return "First name is required";
|
||||
if (!address.phone?.trim() || address.phone.length < 8) return "Valid phone is required";
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- Validation is deterministic and testable
|
||||
- No UI dependencies
|
||||
- Can be reused
|
||||
|
||||
#### 4. **Service Class for Complex Use Cases**
|
||||
```typescript
|
||||
// For cases that need step-by-step control
|
||||
const checkoutService = createCheckoutService(checkoutId);
|
||||
await checkoutService.updateEmail(email);
|
||||
await checkoutService.updateLanguage(locale); // Explicitly called
|
||||
// ... custom logic ...
|
||||
await checkoutService.complete();
|
||||
```
|
||||
|
||||
## Comparison: Before vs After
|
||||
|
||||
| Aspect | Before (Monolithic) | After (Service Layer) |
|
||||
|--------|--------------------|----------------------|
|
||||
| **Lines of code** | 440+ in one function | ~50 in UI, 300 in service |
|
||||
| **Testability** | ❌ Can't unit test | ✅ Each step testable |
|
||||
| **Step ordering** | ❌ Implicit/fragile | ✅ Enforced by structure |
|
||||
| **Failure handling** | ❌ Try/catch spaghetti | ✅ Result-based, explicit |
|
||||
| **Reusability** | ❌ Copy-paste only | ✅ Import and compose |
|
||||
| **Type safety** | ⚠️ Inline types | ✅ Full TypeScript |
|
||||
| **Documentation** | ❌ Comments only | ✅ Code is self-documenting |
|
||||
|
||||
## Critical Business Rules Now Explicit
|
||||
|
||||
```typescript
|
||||
// These rules are now ENFORCED by code, not comments:
|
||||
|
||||
// Rule 1: Language must be set before checkout completion
|
||||
const languageResult = await updateCheckoutLanguage(checkoutId, languageCode);
|
||||
if (!languageResult.success) {
|
||||
return { success: false, error: languageResult.error }; // Pipeline stops!
|
||||
}
|
||||
// Only after success do we proceed to complete...
|
||||
|
||||
// Rule 2: Any step failure stops the pipeline
|
||||
const emailResult = await updateCheckoutEmail(checkoutId, email);
|
||||
if (!emailResult.success) {
|
||||
return { success: false, error: emailResult.error }; // Early return!
|
||||
}
|
||||
|
||||
// Rule 3: Validation happens before any API calls
|
||||
const validationError = validateCheckoutInput(input);
|
||||
if (validationError) {
|
||||
return { success: false, error: validationError }; // Fail fast!
|
||||
}
|
||||
```
|
||||
|
||||
## Why This Won't Break Again
|
||||
|
||||
### 1. **Enforced Ordering**
|
||||
The pipeline function physically cannot complete checkout without first setting the language. It's not a comment—it's code structure.
|
||||
|
||||
### 2. **Fail Fast**
|
||||
Validation happens before any API calls. Invalid data never reaches Saleor.
|
||||
|
||||
### 3. **Explicit Error Handling**
|
||||
Each step returns a `CheckoutStepResult` with `success` boolean. No exceptions for flow control.
|
||||
|
||||
### 4. **Composable Design**
|
||||
If we need to add a new step (e.g., "apply coupon"), we insert it into the pipeline:
|
||||
```typescript
|
||||
const couponResult = await applyCoupon(checkoutId, couponCode);
|
||||
if (!couponResult.success) return { success: false, error: couponResult.error };
|
||||
```
|
||||
The location in the pipeline shows its dependency order.
|
||||
|
||||
### 5. **Type Safety**
|
||||
TypeScript enforces that all required fields are present before the pipeline runs.
|
||||
|
||||
## Migration Path
|
||||
|
||||
### Phase 1: Keep Both (Current)
|
||||
- Old code in `page.tsx` continues to work
|
||||
- New service available for new features
|
||||
- Gradual migration
|
||||
|
||||
### Phase 2: Migrate UI
|
||||
Replace the monolithic `handleSubmit` with service call:
|
||||
```typescript
|
||||
// In page.tsx
|
||||
import { createCheckoutService } from '@/lib/services/checkoutService';
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const checkoutService = createCheckoutService(checkout.id);
|
||||
|
||||
const result = await checkoutService.execute({
|
||||
email: shippingAddress.email,
|
||||
shippingAddress: transformToServiceAddress(shippingAddress),
|
||||
billingAddress: transformToServiceAddress(billingAddress),
|
||||
shippingMethodId: selectedShippingMethod,
|
||||
languageCode: locale,
|
||||
metadata: { phone: shippingAddress.phone, userLanguage: locale },
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
setOrderNumber(result.order!.number);
|
||||
clearCheckout();
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Phase 3: Remove Old Code
|
||||
Once confirmed working, remove the inline mutations from `page.tsx`.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
With the new architecture, we can test each component:
|
||||
|
||||
```typescript
|
||||
// Test individual steps
|
||||
import { updateCheckoutLanguage, validateAddress } from './checkoutService';
|
||||
|
||||
describe('updateCheckoutLanguage', () => {
|
||||
it('should fail if checkout does not exist', async () => {
|
||||
const result = await updateCheckoutLanguage('invalid-id', 'EN');
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAddress', () => {
|
||||
it('should require phone number', () => {
|
||||
const error = validateAddress({ ...validAddress, phone: '' });
|
||||
expect(error).toContain('phone');
|
||||
});
|
||||
});
|
||||
|
||||
// Test full pipeline
|
||||
import { executeCheckoutPipeline } from './checkoutService';
|
||||
|
||||
describe('executeCheckoutPipeline', () => {
|
||||
it('should stop if language update fails', async () => {
|
||||
// Mock language failure
|
||||
jest.spyOn(checkoutService, 'updateCheckoutLanguage').mockResolvedValue({
|
||||
success: false, error: 'Language not supported'
|
||||
});
|
||||
|
||||
const result = await executeCheckoutPipeline(validInput);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Language not supported');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
The previous architecture was **accidentally fragile** because:
|
||||
1. Business rules were implicit (language must be set before complete)
|
||||
2. Step ordering was by convention, not enforcement
|
||||
3. Everything was tightly coupled in one function
|
||||
4. No clear boundaries between concerns
|
||||
|
||||
The new architecture is **intentionally robust** because:
|
||||
1. Business rules are enforced by code structure
|
||||
2. Step ordering is physically enforced by the pipeline
|
||||
3. Each component has a single, clear responsibility
|
||||
4. Strong TypeScript contracts prevent misuse
|
||||
|
||||
**Small changes will no longer break critical functionality** because the architecture makes dependencies explicit and enforces them at compile time and runtime.
|
||||
320
docs/COD-IMPLEMENTATION-PLAN.md
Normal file
320
docs/COD-IMPLEMENTATION-PLAN.md
Normal file
@@ -0,0 +1,320 @@
|
||||
# Cash on Delivery (COD) Implementation Plan
|
||||
|
||||
**Branch:** `feature/cash-on-delivery`
|
||||
**Status:** In Development
|
||||
**Created:** March 29, 2026
|
||||
|
||||
---
|
||||
|
||||
## 1. ARCHITECTURE DECISIONS
|
||||
|
||||
### Payment Method Type: Simple Transaction
|
||||
- Uses Saleor's native `Transaction` objects
|
||||
- No Payment App required (COD is manual payment)
|
||||
- Creates transaction with status `NOT_CHARGED`
|
||||
- Staff marks as paid via Dashboard when cash collected
|
||||
|
||||
### Why This Approach:
|
||||
- ✅ Native Saleor data structures
|
||||
- ✅ Appears in Dashboard automatically
|
||||
- ✅ No metadata hacks
|
||||
- ✅ Extensible to other simple payments (Bank Transfer)
|
||||
- ✅ Compatible with Payment Apps later (Stripe, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 2. FILE STRUCTURE
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── config/
|
||||
│ │ └── paymentMethods.ts # Payment methods configuration
|
||||
│ └── saleor/
|
||||
│ └── payments/
|
||||
│ ├── types.ts # Payment type definitions
|
||||
│ ├── cod.ts # COD-specific logic
|
||||
│ └── createTransaction.ts # Generic transaction creator
|
||||
│
|
||||
├── components/
|
||||
│ └── payment/
|
||||
│ ├── PaymentMethodSelector.tsx # Payment method selection UI
|
||||
│ ├── PaymentMethodCard.tsx # Individual payment card
|
||||
│ └── CODInstructions.tsx # COD-specific instructions
|
||||
│
|
||||
├── app/[locale]/checkout/
|
||||
│ ├── page.tsx # Updated checkout page
|
||||
│ └── components/
|
||||
│ └── PaymentSection.tsx # Checkout payment section wrapper
|
||||
│
|
||||
└── i18n/messages/
|
||||
├── en.json # Payment translations
|
||||
├── sr.json # Payment translations
|
||||
├── de.json # Payment translations
|
||||
└── fr.json # Payment translations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. DATA MODELS
|
||||
|
||||
### PaymentMethod Interface
|
||||
```typescript
|
||||
interface PaymentMethod {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'simple' | 'app';
|
||||
fee: number;
|
||||
available: boolean;
|
||||
availableInChannels: string[];
|
||||
icon?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### COD Transaction Structure
|
||||
```typescript
|
||||
const codTransaction = {
|
||||
name: "Cash on Delivery",
|
||||
pspReference: `COD-${orderNumber}-${timestamp}`,
|
||||
availableActions: ["CHARGE"],
|
||||
amountAuthorized: { amount: 0, currency: "RSD" },
|
||||
amountCharged: { amount: 0, currency: "RSD" }
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. IMPLEMENTATION PHASES
|
||||
|
||||
### Phase 1: Configuration & Types (Files 1-3)
|
||||
**Files:**
|
||||
1. `lib/config/paymentMethods.ts` - Payment methods config
|
||||
2. `lib/saleor/payments/types.ts` - Type definitions
|
||||
3. `lib/saleor/payments/cod.ts` - COD transaction logic
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] Payment methods configuration
|
||||
- [ ] TypeScript interfaces
|
||||
- [ ] COD transaction creation function
|
||||
|
||||
### Phase 2: UI Components (Files 4-6)
|
||||
**Files:**
|
||||
4. `components/payment/PaymentMethodCard.tsx`
|
||||
5. `components/payment/PaymentMethodSelector.tsx`
|
||||
6. `components/payment/CODInstructions.tsx`
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] Payment method selection UI
|
||||
- [ ] COD instructions component
|
||||
- [ ] Responsive design
|
||||
|
||||
### Phase 3: Checkout Integration (Files 7-8)
|
||||
**Files:**
|
||||
7. `app/[locale]/checkout/components/PaymentSection.tsx`
|
||||
8. `app/[locale]/checkout/page.tsx` (updated)
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] Payment section in checkout
|
||||
- [ ] Integration with checkout flow
|
||||
- [ ] Transaction creation on complete
|
||||
|
||||
### Phase 4: Translations (Files 9-12)
|
||||
**Files:**
|
||||
9-12. Update `i18n/messages/{en,sr,de,fr}.json`
|
||||
|
||||
**Deliverables:**
|
||||
- [ ] All translation keys
|
||||
- [ ] Serbian, English, German, French
|
||||
|
||||
### Phase 5: Testing
|
||||
**Tasks:**
|
||||
- [ ] Test COD flow end-to-end
|
||||
- [ ] Verify transaction created in Saleor
|
||||
- [ ] Test mobile responsiveness
|
||||
- [ ] Test locale switching
|
||||
|
||||
---
|
||||
|
||||
## 5. CHECKOUT FLOW
|
||||
|
||||
```
|
||||
1. User adds items to cart
|
||||
↓
|
||||
2. User proceeds to checkout
|
||||
↓
|
||||
3. Checkout page loads with:
|
||||
- Contact form (email, phone)
|
||||
- Shipping address form
|
||||
- Billing address form (same as shipping default)
|
||||
- Shipping method selector
|
||||
- PAYMENT METHOD SELECTOR (NEW)
|
||||
└─ COD selected by default
|
||||
- Order summary
|
||||
- Complete Order button
|
||||
↓
|
||||
4. User fills all required fields
|
||||
↓
|
||||
5. User clicks "Complete Order"
|
||||
↓
|
||||
6. System:
|
||||
a. Validates all fields
|
||||
b. Creates order via checkoutComplete
|
||||
c. Creates COD Transaction on order
|
||||
d. Redirects to order confirmation
|
||||
↓
|
||||
7. Order Confirmation page shows:
|
||||
- Order number
|
||||
- Total amount
|
||||
- Payment method: "Cash on Delivery"
|
||||
- Instructions: "Please prepare cash for delivery"
|
||||
↓
|
||||
8. Staff sees order in Dashboard:
|
||||
- Status: UNFULFILLED
|
||||
- Payment Status: NOT_CHARGED
|
||||
- Transaction: "Cash on Delivery (COD-123)"
|
||||
↓
|
||||
9. On delivery:
|
||||
- Delivery person collects cash
|
||||
- Staff marks order as FULFILLED in Dashboard
|
||||
- (Optional: Create CHARGE_SUCCESS transaction event)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. SALESOR DASHBOARD VIEW
|
||||
|
||||
### Order Details:
|
||||
```
|
||||
Order #1234
|
||||
├─ Status: UNFULFILLED
|
||||
├─ Payment Status: NOT_CHARGED
|
||||
├─ Transactions:
|
||||
│ └─ Cash on Delivery (COD-1234-1743214567890)
|
||||
│ ├─ Status: NOT_CHARGED
|
||||
│ ├─ Amount: 3,200 RSD
|
||||
│ └─ Available Actions: [CHARGE]
|
||||
└─ Actions: [Fulfill] [Cancel]
|
||||
```
|
||||
|
||||
### When Cash Collected:
|
||||
```
|
||||
Staff clicks [Fulfill]
|
||||
↓
|
||||
Order Status: FULFILLED
|
||||
Payment Status: (still NOT_CHARGED, but order is complete)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. TRANSLATION KEYS
|
||||
|
||||
### English (en.json):
|
||||
```json
|
||||
{
|
||||
"Payment": {
|
||||
"title": "Payment Method",
|
||||
"cod": {
|
||||
"name": "Cash on Delivery",
|
||||
"description": "Pay when you receive your order",
|
||||
"instructions": {
|
||||
"title": "Payment Instructions",
|
||||
"prepareCash": "Please prepare the exact amount in cash",
|
||||
"inspectOrder": "You can inspect your order before paying",
|
||||
"noFee": "No additional fee for cash on delivery"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"name": "Credit Card",
|
||||
"description": "Secure online payment",
|
||||
"comingSoon": "Coming soon"
|
||||
},
|
||||
"selectMethod": "Select payment method",
|
||||
"securePayment": "Secure payment processing"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Serbian (sr.json):
|
||||
```json
|
||||
{
|
||||
"Payment": {
|
||||
"title": "Način Plaćanja",
|
||||
"cod": {
|
||||
"name": "Plaćanje Pouzećem",
|
||||
"description": "Platite kada primite porudžbinu",
|
||||
"instructions": {
|
||||
"title": "Uputstva za Plaćanje",
|
||||
"prepareCash": "Pripremite tačan iznos u gotovini",
|
||||
"inspectOrder": "Možete pregledati porudžbinu pre plaćanja",
|
||||
"noFee": "Bez dodatne naknade za plaćanje pouzećem"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. TESTING CHECKLIST
|
||||
|
||||
### Functional Tests:
|
||||
- [ ] COD radio button selected by default
|
||||
- [ ] Payment section visible in checkout
|
||||
- [ ] Order completes with COD selected
|
||||
- [ ] Transaction created with correct details
|
||||
- [ ] Transaction visible in Saleor Dashboard
|
||||
- [ ] Order confirmation shows COD
|
||||
- [ ] Translations work in all locales
|
||||
|
||||
### Edge Cases:
|
||||
- [ ] Checkout validation fails - payment method preserved
|
||||
- [ ] Network error during transaction creation
|
||||
- [ ] User switches payment methods (when multiple available)
|
||||
- [ ] Mobile viewport - payment section responsive
|
||||
|
||||
### Integration Tests:
|
||||
- [ ] End-to-end COD flow
|
||||
- [ ] Order appears in Dashboard
|
||||
- [ ] Staff can fulfill COD order
|
||||
- [ ] Multiple payment methods display correctly
|
||||
|
||||
---
|
||||
|
||||
## 9. FUTURE ENHANCEMENTS
|
||||
|
||||
### Phase 2 (Post-MVP):
|
||||
- [ ] Add Bank Transfer payment method
|
||||
- [ ] Payment method icons
|
||||
- [ ] Save payment preference for logged-in users
|
||||
|
||||
### Phase 3 (Advanced):
|
||||
- [ ] Bitcoin (manual) payment method
|
||||
- [ ] Bitcoin (automated) via custom handler
|
||||
- [ ] Payment Apps integration (Stripe, etc.)
|
||||
|
||||
---
|
||||
|
||||
## 10. NOTES
|
||||
|
||||
### Why No Metadata:
|
||||
- Saleor has native Transaction objects
|
||||
- Transactions are typed and validated
|
||||
- Appear in Dashboard automatically
|
||||
- Support proper lifecycle (NOT_CHARGED → CHARGED)
|
||||
|
||||
### Why Simple Type (Not App):
|
||||
- COD doesn't need async processing
|
||||
- No external API to integrate
|
||||
- No PCI compliance requirements
|
||||
- Manual verification by staff
|
||||
|
||||
### Compatibility:
|
||||
- Current architecture supports Payment Apps later
|
||||
- Can add Stripe/PayPal as `type: 'app'` without breaking COD
|
||||
- Bitcoin can be added as `type: 'async'` when ready
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** March 29, 2026
|
||||
**Next Review:** After Phase 1 completion
|
||||
666
docs/roadmap/FEATURE_ROADMAP.md
Normal file
666
docs/roadmap/FEATURE_ROADMAP.md
Normal file
@@ -0,0 +1,666 @@
|
||||
# Storefront Feature Roadmap
|
||||
|
||||
> Strategic roadmap for increasing profitability, conversion rates, and SEO traffic.
|
||||
|
||||
## Quick Stats
|
||||
- **Total Features:** 20
|
||||
- **Estimated Timeline:** 12-16 weeks
|
||||
- **Priority Categories:** Foundation → Quick Wins → Revenue → Growth
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation (Weeks 1-3)
|
||||
*These features must be completed first as they enable other features*
|
||||
|
||||
### 1. Enhanced Product Reviews System
|
||||
**Impact:** High | **Effort:** Medium | **Revenue Impact:** +15-30% conversion
|
||||
|
||||
**Description:**
|
||||
- Allow customers to submit reviews with photos
|
||||
- Star ratings display on product cards
|
||||
- "Verified Purchase" badges
|
||||
- Review moderation dashboard
|
||||
- Review request email automation
|
||||
|
||||
**Why First:**
|
||||
- Required for Rich Snippets (SEO feature #9)
|
||||
- Social proof enables all conversion optimizations
|
||||
- Reviews feed into email sequences
|
||||
|
||||
**Technical Requirements:**
|
||||
- Database schema for reviews
|
||||
- Image upload/storage (S3/MinIO)
|
||||
- Moderation workflow
|
||||
- Saleor integration or standalone system
|
||||
|
||||
**Dependencies:** None (foundation feature)
|
||||
|
||||
---
|
||||
|
||||
### 2. Structured Data / Rich Snippets (JSON-LD)
|
||||
**Impact:** High | **Effort:** Low | **Revenue Impact:** +10-20% CTR
|
||||
|
||||
**Description:**
|
||||
- Product Schema (price, availability, ratings)
|
||||
- Review Schema (star ratings in Google)
|
||||
- Organization Schema (brand info)
|
||||
- BreadcrumbList Schema (navigation in SERPs)
|
||||
- FAQ Schema for product pages
|
||||
|
||||
**Why First:**
|
||||
- Needs reviews system (#1) for review schema
|
||||
- Immediate SEO benefit
|
||||
- No dependencies after reviews
|
||||
|
||||
**Technical Requirements:**
|
||||
- next/head component for JSON-LD injection
|
||||
- Dynamic schema generation per page
|
||||
- Testing with Google's Rich Results Test
|
||||
|
||||
**Dependencies:**
|
||||
- ✅ Product Reviews System (#1) - for review ratings
|
||||
- ⏳ Product catalog (already exists)
|
||||
|
||||
---
|
||||
|
||||
### 3. Open Graph & Twitter Card Meta Tags
|
||||
**Impact:** Medium | **Effort:** Low | **Revenue Impact:** Social sharing boost
|
||||
|
||||
**Description:**
|
||||
- og:title, og:description, og:image for all pages
|
||||
- Twitter Card meta tags
|
||||
- Dynamic meta tags for product pages
|
||||
- Social share preview optimization
|
||||
|
||||
**Why First:**
|
||||
- Quick win, low effort
|
||||
- Improves social media traffic quality
|
||||
|
||||
**Technical Requirements:**
|
||||
- Extend existing metadata.ts
|
||||
- Generate dynamic OG images (optional)
|
||||
|
||||
**Dependencies:** None (parallel with #2)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Quick Wins (Weeks 4-5)
|
||||
*High impact, low effort features that show immediate results*
|
||||
|
||||
### 4. Free Shipping Progress Bar
|
||||
**Impact:** High | **Effort:** Low | **Revenue Impact:** +15-25% AOV
|
||||
|
||||
**Description:**
|
||||
- Visual progress bar in cart drawer
|
||||
- "Add X RSD more for free shipping" messaging
|
||||
- Animated progress indicator
|
||||
- Threshold: 5,000 RSD (already configured)
|
||||
|
||||
**Why Now:**
|
||||
- Increases average order value immediately
|
||||
- Simple cart component modification
|
||||
- No backend dependencies
|
||||
|
||||
**Technical Requirements:**
|
||||
- Cart drawer component update
|
||||
- Real-time calculation based on cart total
|
||||
- Confetti animation when threshold reached (optional)
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
---
|
||||
|
||||
### 5. Sticky "Add to Cart" Button (Mobile)
|
||||
**Impact:** High | **Effort:** Low | **Revenue Impact:** +10-20% mobile conversion
|
||||
|
||||
**Description:**
|
||||
- Fixed position button on mobile product pages
|
||||
- Price and "Add to Cart" always visible while scrolling
|
||||
- Smooth scroll to variant selector if needed
|
||||
|
||||
**Why Now:**
|
||||
- Mobile is likely 60%+ of traffic
|
||||
- Single component change
|
||||
- High conversion impact
|
||||
|
||||
**Technical Requirements:**
|
||||
- CSS position: sticky/fixed
|
||||
- Mobile breakpoint detection
|
||||
- Smooth scroll behavior
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
---
|
||||
|
||||
### 6. Trust Signals Enhancement
|
||||
**Impact:** Medium | **Effort:** Low | **Revenue Impact:** +5-10% conversion
|
||||
|
||||
**Description:**
|
||||
- Payment method icons (Visa, Mastercard, PayPal) in footer/checkout
|
||||
- "Secure SSL Checkout" badge
|
||||
- 30-day money-back guarantee badge
|
||||
- "Made in Serbia" / local production badge
|
||||
|
||||
**Why Now:**
|
||||
- Reduces checkout anxiety
|
||||
- Visual asset creation only
|
||||
- No code complexity
|
||||
|
||||
**Technical Requirements:**
|
||||
- SVG icons for payment methods
|
||||
- Badge component updates
|
||||
- Footer component modification
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Revenue Optimization (Weeks 6-10)
|
||||
*Features that directly increase revenue and LTV*
|
||||
|
||||
### 7. Abandoned Cart Recovery System
|
||||
**Impact:** Critical | **Effort:** Medium | **Revenue Impact:** 10-15% cart recovery
|
||||
|
||||
**Description:**
|
||||
- 3-email sequence: 1 hour, 24 hours, 72 hours
|
||||
- Email 3 includes 10% discount code
|
||||
- Exit intent detection
|
||||
- SMS fallback (optional)
|
||||
- Recovery tracking dashboard
|
||||
|
||||
**Why Now:**
|
||||
- Highest ROI feature
|
||||
- Requires email infrastructure
|
||||
- Builds on existing order system
|
||||
|
||||
**Technical Requirements:**
|
||||
- Cart abandonment detection
|
||||
- Email template system (extend existing)
|
||||
- Discount code generation
|
||||
- Cron job or queue system
|
||||
- Tracking pixel for recovery attribution
|
||||
|
||||
**Dependencies:**
|
||||
- ✅ Email service (Resend already configured)
|
||||
- ✅ Order notification service (already exists)
|
||||
- ⏳ Discount code system (if not in Saleor)
|
||||
|
||||
---
|
||||
|
||||
### 8. One-Click Upsells at Checkout
|
||||
**Impact:** High | **Effort:** Medium | **Revenue Impact:** +20-30% AOV
|
||||
|
||||
**Description:**
|
||||
- "Complete your routine" modal after add-to-cart
|
||||
- Smart product recommendations based on cart contents
|
||||
- One-click add (no page reload)
|
||||
- Bundle discounts (buy 2 get 10% off)
|
||||
|
||||
**Why Now:**
|
||||
- Increases AOV significantly
|
||||
- Leverages existing cart system
|
||||
- Works well with skincare routines
|
||||
|
||||
**Technical Requirements:**
|
||||
- Upsell algorithm (category-based)
|
||||
- Modal component
|
||||
- Cart API updates
|
||||
- Bundle pricing logic
|
||||
|
||||
**Dependencies:**
|
||||
- ✅ Cart system (already exists)
|
||||
- ⏳ Product relationships data (manual or AI-based)
|
||||
|
||||
---
|
||||
|
||||
### 9. Exit-Intent Lead Capture Popup
|
||||
**Impact:** Medium | **Effort:** Low | **Revenue Impact:** +5-15% email list growth
|
||||
|
||||
**Description:**
|
||||
- Detects when user moves mouse to close tab/address bar
|
||||
- Shows email signup with 10% discount offer
|
||||
- Mobile: scroll-up detection or time-based
|
||||
- Dismissible with "No thanks" option
|
||||
|
||||
**Why Now:**
|
||||
- Captures leaving traffic
|
||||
- Builds email list for newsletters
|
||||
- Simple implementation
|
||||
|
||||
**Technical Requirements:**
|
||||
- Exit intent detection library (ouibounce or custom)
|
||||
- Email capture form
|
||||
- Discount code integration
|
||||
- Cookie/session management (show once per user)
|
||||
|
||||
**Dependencies:**
|
||||
- ⏳ Email list management (CRM or Mailchimp)
|
||||
- ⏳ Discount code system
|
||||
|
||||
---
|
||||
|
||||
### 10. Subscription / Recurring Orders
|
||||
**Impact:** High | **Effort:** High | **Revenue Impact:** Predictable recurring revenue
|
||||
|
||||
**Description:**
|
||||
- "Subscribe & Save 15%" option on product pages
|
||||
- Monthly/quarterly delivery intervals
|
||||
- Automatic billing (Stripe subscriptions)
|
||||
- Skip/pause/cancel management portal
|
||||
- Replenishment reminders
|
||||
|
||||
**Why Now:**
|
||||
- Skincare has high reorder rates
|
||||
- Predictable revenue stream
|
||||
- Increases LTV significantly
|
||||
|
||||
**Technical Requirements:**
|
||||
- Stripe Subscription integration
|
||||
- Customer portal for management
|
||||
- Inventory forecasting
|
||||
- Email notifications for upcoming orders
|
||||
|
||||
**Dependencies:**
|
||||
- ✅ Stripe integration (check existing)
|
||||
- ⏳ Customer account system (if not exists)
|
||||
- ⏳ Inventory management enhancements
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Engagement & Support (Weeks 11-12)
|
||||
*Features that improve customer experience and reduce friction*
|
||||
|
||||
### 11. Live Chat Widget (WhatsApp Business)
|
||||
**Impact:** Medium | **Effort:** Low | **Revenue Impact:** +10-15% conversion
|
||||
|
||||
**Description:**
|
||||
- WhatsApp Business integration (most popular in Serbia)
|
||||
- Floating chat button
|
||||
- Auto-reply for common questions
|
||||
- Business hours indicator
|
||||
- Chat history
|
||||
|
||||
**Why Now:**
|
||||
- Real-time customer support
|
||||
- High trust factor for skincare advice
|
||||
- Low implementation cost
|
||||
|
||||
**Technical Requirements:**
|
||||
- WhatsApp Business API or click-to-chat
|
||||
- Floating button component
|
||||
- Auto-response templates
|
||||
- Mobile-optimized
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
---
|
||||
|
||||
### 12. Product Comparison Tool
|
||||
**Impact:** Medium | **Effort:** Medium | **Revenue Impact:** +5-10% conversion
|
||||
|
||||
**Description:**
|
||||
- Compare 2-3 products side-by-side
|
||||
- Compare ingredients, benefits, price, reviews
|
||||
- Save comparison for later
|
||||
- "Help me choose" quiz (optional)
|
||||
|
||||
**Why Now:**
|
||||
- Reduces decision paralysis
|
||||
- Increases time on site
|
||||
- Helps customers find right product
|
||||
|
||||
**Technical Requirements:**
|
||||
- Comparison table component
|
||||
- Product selection interface
|
||||
- Data normalization across products
|
||||
- Persistent state (URL params or session)
|
||||
|
||||
**Dependencies:**
|
||||
- ✅ Product data (already in Saleor)
|
||||
- ⏳ Enhanced product attributes
|
||||
|
||||
---
|
||||
|
||||
### 13. Enhanced Urgency Elements
|
||||
**Impact:** Medium | **Effort:** Low | **Revenue Impact:** +5-15% conversion
|
||||
|
||||
**Description:**
|
||||
- Real stock counter ("Only 3 left in stock")
|
||||
- Countdown timer for limited promotions
|
||||
- Recent purchase notifications ("Sarah from Belgrade just bought...")
|
||||
- Low stock email alerts
|
||||
|
||||
**Why Now:**
|
||||
- Scarcity drives action
|
||||
- Builds on existing urgency text
|
||||
- Simple implementation
|
||||
|
||||
**Technical Requirements:**
|
||||
- Real-time stock display
|
||||
- Countdown timer component
|
||||
- Fake social proof (recent purchase ticker)
|
||||
- Sale scheduling system
|
||||
|
||||
**Dependencies:**
|
||||
- ✅ Inventory data from Saleor
|
||||
- ⏳ Sale/promotion management system
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Content & SEO Growth (Weeks 13-16)
|
||||
*Long-term traffic growth through content and SEO*
|
||||
|
||||
### 14. Blog / Content Marketing Hub
|
||||
**Impact:** High | **Effort:** High | **Revenue Impact:** Organic traffic growth
|
||||
|
||||
**Description:**
|
||||
- Blog section with categories
|
||||
- Skincare guides and tutorials
|
||||
- Ingredient education
|
||||
- Before/after case studies
|
||||
- Video content integration
|
||||
- SEO-optimized articles
|
||||
|
||||
**Why Now:**
|
||||
- Long-term organic traffic
|
||||
- Positions brand as authority
|
||||
- Content for social media
|
||||
|
||||
**Technical Requirements:**
|
||||
- Blog CMS (Headless CMS or markdown)
|
||||
- Category/tags system
|
||||
- Author profiles
|
||||
- Related articles
|
||||
- Comment system (optional)
|
||||
|
||||
**Dependencies:**
|
||||
- ⏳ Headless CMS (Strapi, Sanity, or Contentful)
|
||||
- ⏳ Content strategy and writing resources
|
||||
|
||||
---
|
||||
|
||||
### 15. Enhanced Product Pages (Video & Guides)
|
||||
**Impact:** Medium | **Effort:** Medium | **Revenue Impact:** +10-20% conversion
|
||||
|
||||
**Description:**
|
||||
- Product application tutorial videos
|
||||
- Ingredient glossary popup
|
||||
- "How to use" photo guides
|
||||
- Skin type recommendations
|
||||
- Routine builder tool
|
||||
|
||||
**Why Now:**
|
||||
- Increases product understanding
|
||||
- Reduces returns
|
||||
- Video content for social
|
||||
|
||||
**Technical Requirements:**
|
||||
- Video hosting (Vimeo/YouTube)
|
||||
- Accordion components for guides
|
||||
- Skin type quiz logic
|
||||
- Rich media product gallery
|
||||
|
||||
**Dependencies:**
|
||||
- ⏳ Video production
|
||||
- ⏳ Content creation
|
||||
|
||||
---
|
||||
|
||||
### 16. FAQ Section with Schema Markup
|
||||
**Impact:** Medium | **Effort:** Low | **Revenue Impact:** SEO + reduced support
|
||||
|
||||
**Description:**
|
||||
- Comprehensive FAQ page
|
||||
- Product-specific FAQs
|
||||
- Searchable FAQ
|
||||
- FAQ schema markup for Google
|
||||
- Categorized questions
|
||||
|
||||
**Why Now:**
|
||||
- Reduces customer service load
|
||||
- SEO benefit with FAQ schema
|
||||
- Easy content creation
|
||||
|
||||
**Technical Requirements:**
|
||||
- FAQ accordion component
|
||||
- Search functionality
|
||||
- JSON-LD FAQ schema
|
||||
- Category filtering
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Email Marketing Automation (Weeks 14-16)
|
||||
*Leveraging email for retention and LTV*
|
||||
|
||||
### 17. Post-Purchase Email Sequence
|
||||
**Impact:** High | **Effort:** Medium | **Revenue Impact:** +20-30% retention
|
||||
|
||||
**Description:**
|
||||
- Order confirmation (already exists ✓)
|
||||
- Shipping notification (already exists ✓)
|
||||
- Delivery confirmation
|
||||
- "How's your product?" (7 days later)
|
||||
- Review request (14 days later)
|
||||
- Replenishment reminder (30/60 days)
|
||||
- Win-back campaign (90 days no purchase)
|
||||
|
||||
**Why Now:**
|
||||
- Maximizes LTV
|
||||
- Uses existing email infrastructure
|
||||
- Automated revenue
|
||||
|
||||
**Technical Requirements:**
|
||||
- Email sequence automation
|
||||
- Timing logic based on delivery
|
||||
- Dynamic content based on purchase
|
||||
- Unsubscribe management
|
||||
|
||||
**Dependencies:**
|
||||
- ✅ Email service (Resend)
|
||||
- ✅ Order tracking (already exists)
|
||||
- ⏳ Delivery tracking integration (optional)
|
||||
|
||||
---
|
||||
|
||||
### 18. Segment-Based Email Campaigns
|
||||
**Impact:** Medium | **Effort:** Medium | **Revenue Impact:** +15-25% email revenue
|
||||
|
||||
**Description:**
|
||||
- VIP customers segment (high LTV)
|
||||
- Inactive customers (win-back offers)
|
||||
- Product-specific education sequences
|
||||
- Seasonal campaigns (winter skincare, summer protection)
|
||||
- Birthday discounts
|
||||
|
||||
**Why Now:**
|
||||
- Personalized marketing
|
||||
- Higher engagement than broadcasts
|
||||
- Uses customer data
|
||||
|
||||
**Technical Requirements:**
|
||||
- Customer segmentation logic
|
||||
- Email template variants
|
||||
- Automation workflows
|
||||
- A/B testing capability
|
||||
|
||||
**Dependencies:**
|
||||
- ✅ Email service
|
||||
- ⏳ CRM or customer data platform
|
||||
- ⏳ Email marketing platform (Mailchimp, Klaviyo, or custom)
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Advanced Features (Future)
|
||||
*Nice-to-have features for later phases*
|
||||
|
||||
### 19. Wishlist / Save for Later
|
||||
**Impact:** Medium | **Effort:** Medium | **Revenue Impact:** +5-10% conversion
|
||||
|
||||
**Description:**
|
||||
- Heart icon on product cards
|
||||
- Save items without account (cookies) or with account
|
||||
- Email reminders for saved items
|
||||
- Share wishlist feature
|
||||
- Back-in-stock notifications
|
||||
|
||||
**Technical Requirements:**
|
||||
- Wishlist database/storage
|
||||
- Heart icon toggle
|
||||
- Wishlist page
|
||||
- Email triggers
|
||||
- Social sharing
|
||||
|
||||
**Dependencies:**
|
||||
- ⏳ Customer account system (optional)
|
||||
- ⏳ Back-in-stock notification system
|
||||
|
||||
---
|
||||
|
||||
### 20. Google Analytics 4 + Enhanced E-commerce
|
||||
**Impact:** Medium | **Effort:** Low | **Revenue Impact:** Better attribution
|
||||
|
||||
**Description:**
|
||||
- GA4 implementation alongside OpenPanel
|
||||
- Enhanced e-commerce events
|
||||
- Funnel visualization
|
||||
- Attribution modeling
|
||||
- A/B testing framework (Google Optimize)
|
||||
|
||||
**Why Later:**
|
||||
- OpenPanel already provides analytics
|
||||
- GA4 is supplementary
|
||||
- Data analysis takes time
|
||||
|
||||
**Technical Requirements:**
|
||||
- GA4 script injection
|
||||
- Event mapping to GA4 standards
|
||||
- E-commerce data layer
|
||||
- Conversion tracking setup
|
||||
|
||||
**Dependencies:** None (can be done anytime)
|
||||
|
||||
---
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
Phase 1: Foundation
|
||||
├── 1. Product Reviews (START HERE)
|
||||
├── 2. Structured Data ← depends on #1
|
||||
└── 3. Open Graph Tags (parallel)
|
||||
|
||||
Phase 2: Quick Wins
|
||||
├── 4. Free Shipping Bar (independent)
|
||||
├── 5. Sticky Add to Cart (independent)
|
||||
└── 6. Trust Signals (independent)
|
||||
|
||||
Phase 3: Revenue
|
||||
├── 7. Abandoned Cart ← needs email system ✓
|
||||
├── 8. One-Click Upsells ← needs cart ✓
|
||||
├── 9. Exit Intent ← needs email CRM
|
||||
└── 10. Subscriptions ← needs Stripe
|
||||
|
||||
Phase 4: Engagement
|
||||
├── 11. Live Chat (independent)
|
||||
├── 12. Product Comparison ← needs product data ✓
|
||||
└── 13. Urgency Elements ← needs inventory ✓
|
||||
|
||||
Phase 5: Content
|
||||
├── 14. Blog ← needs CMS
|
||||
├── 15. Enhanced PDPs ← needs video content
|
||||
└── 16. FAQ (independent)
|
||||
|
||||
Phase 6: Email
|
||||
├── 17. Post-Purchase ← needs #7 foundation
|
||||
└── 18. Segmentation ← needs CRM
|
||||
|
||||
Phase 7: Future
|
||||
├── 19. Wishlist (nice to have)
|
||||
└── 20. GA4 (supplementary)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Priority Matrix
|
||||
|
||||
| Feature | Revenue Impact | SEO Impact | Effort | Priority |
|
||||
|---------|---------------|------------|--------|----------|
|
||||
| 1. Product Reviews | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Medium | **P0** |
|
||||
| 2. Structured Data | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Low | **P0** |
|
||||
| 7. Abandoned Cart | ⭐⭐⭐⭐⭐ | ⭐ | Medium | **P0** |
|
||||
| 4. Free Shipping Bar | ⭐⭐⭐⭐ | ⭐ | Low | **P1** |
|
||||
| 8. One-Click Upsells | ⭐⭐⭐⭐⭐ | ⭐ | Medium | **P1** |
|
||||
| 5. Sticky Add to Cart | ⭐⭐⭐⭐ | ⭐ | Low | **P1** |
|
||||
| 10. Subscriptions | ⭐⭐⭐⭐⭐ | ⭐ | High | **P1** |
|
||||
| 17. Post-Purchase Email | ⭐⭐⭐⭐ | ⭐ | Medium | **P1** |
|
||||
| 14. Blog | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | High | **P2** |
|
||||
| 9. Exit Intent | ⭐⭐⭐ | ⭐ | Low | **P2** |
|
||||
| 11. Live Chat | ⭐⭐⭐ | ⭐ | Low | **P2** |
|
||||
| 15. Enhanced PDPs | ⭐⭐⭐⭐ | ⭐⭐⭐ | Medium | **P2** |
|
||||
|
||||
**Legend:**
|
||||
- **P0:** Start immediately, highest ROI
|
||||
- **P1:** Core revenue features
|
||||
- **P2:** Growth and optimization
|
||||
|
||||
---
|
||||
|
||||
## Resource Requirements
|
||||
|
||||
### Development Team
|
||||
- **Frontend:** 1-2 developers (Next.js/React)
|
||||
- **Backend:** 1 developer (Node.js/GraphQL)
|
||||
- **DevOps:** Part-time (CI/CD, infrastructure)
|
||||
|
||||
### External Resources
|
||||
- **Content Writer:** For blog, FAQs, product descriptions
|
||||
- **Video Production:** For tutorials and guides
|
||||
- **Email Copywriter:** For email sequences
|
||||
- **Designer:** For banners, badges, marketing assets
|
||||
|
||||
### Third-Party Services
|
||||
- **Email Marketing:** Resend (✓), Klaviyo (optional upgrade)
|
||||
- **Reviews Platform:** Loox, Judge.me, or custom
|
||||
- **Live Chat:** WhatsApp Business (free), Intercom (paid)
|
||||
- **Analytics:** OpenPanel (✓), Google Analytics 4
|
||||
- **CMS:** Strapi (self-hosted) or Sanity
|
||||
- **CDN:** Cloudflare (✓)
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Revenue KPIs
|
||||
- **Conversion Rate:** Current → Target (+20%)
|
||||
- **Average Order Value:** Current → Target (+25%)
|
||||
- **Customer Lifetime Value:** Current → Target (+40%)
|
||||
- **Cart Abandonment Rate:** Current → Target (-30%)
|
||||
|
||||
### SEO KPIs
|
||||
- **Organic Traffic:** +50% in 6 months
|
||||
- **Click-Through Rate:** +15% with rich snippets
|
||||
- **Keyword Rankings:** Top 3 for 20 target keywords
|
||||
- **Domain Authority:** Increase by 10 points
|
||||
|
||||
### Engagement KPIs
|
||||
- **Email List Growth:** +500 subscribers/month
|
||||
- **Review Submission Rate:** 10% of orders
|
||||
- **Repeat Purchase Rate:** 30% within 90 days
|
||||
- **Customer Support Tickets:** -20% with FAQ
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Test everything:** A/B test major changes
|
||||
- **Mobile-first:** 60%+ traffic is mobile
|
||||
- **Performance:** Keep Core Web Vitals green
|
||||
- **Accessibility:** WCAG 2.1 AA compliance
|
||||
- **Privacy:** GDPR compliance for EU customers
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: March 2026*
|
||||
*Next Review: Quarterly*
|
||||
47
src/app/[locale]/checkout/components/PaymentSection.tsx
Normal file
47
src/app/[locale]/checkout/components/PaymentSection.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { PaymentMethodSelector, CODInstructions } from "@/components/payment";
|
||||
import { getPaymentMethodsForChannel } from "@/lib/config/paymentMethods";
|
||||
import type { PaymentMethod } from "@/lib/saleor/payments/types";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface PaymentSectionProps {
|
||||
selectedMethodId: string;
|
||||
onSelectMethod: (methodId: string) => void;
|
||||
locale: string;
|
||||
channel?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function PaymentSection({
|
||||
selectedMethodId,
|
||||
onSelectMethod,
|
||||
locale,
|
||||
channel = "default-channel",
|
||||
disabled = false,
|
||||
}: PaymentSectionProps) {
|
||||
const t = useTranslations("Payment");
|
||||
|
||||
// Get available payment methods for this channel
|
||||
const paymentMethods: PaymentMethod[] = getPaymentMethodsForChannel(channel);
|
||||
|
||||
// Get the selected method details
|
||||
const selectedMethod = paymentMethods.find((m) => m.id === selectedMethodId);
|
||||
|
||||
return (
|
||||
<section className="border-t border-gray-200 pt-6">
|
||||
<PaymentMethodSelector
|
||||
methods={paymentMethods}
|
||||
selectedMethodId={selectedMethodId}
|
||||
onSelectMethod={onSelectMethod}
|
||||
locale={locale}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* COD instructions can be shown here if needed */}
|
||||
{selectedMethod?.id === "cod" && (
|
||||
<CODInstructions />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -13,14 +13,12 @@ import { saleorClient } from "@/lib/saleor/client";
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import {
|
||||
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||
CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||
CHECKOUT_COMPLETE,
|
||||
CHECKOUT_EMAIL_UPDATE,
|
||||
CHECKOUT_METADATA_UPDATE,
|
||||
CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||
} from "@/lib/saleor/mutations/Checkout";
|
||||
import { PaymentSection } from "./components/PaymentSection";
|
||||
import { DEFAULT_PAYMENT_METHOD } from "@/lib/config/paymentMethods";
|
||||
import { GET_CHECKOUT_BY_ID } from "@/lib/saleor/queries/Checkout";
|
||||
import type { Checkout } from "@/types/saleor";
|
||||
import { createCheckoutService, type Address } from "@/lib/services/checkoutService";
|
||||
|
||||
interface ShippingAddressUpdateResponse {
|
||||
checkoutShippingAddressUpdate?: {
|
||||
@@ -29,44 +27,6 @@ interface ShippingAddressUpdateResponse {
|
||||
};
|
||||
}
|
||||
|
||||
interface BillingAddressUpdateResponse {
|
||||
checkoutBillingAddressUpdate?: {
|
||||
checkout?: Checkout;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckoutCompleteResponse {
|
||||
checkoutComplete?: {
|
||||
order?: { number: string };
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface EmailUpdateResponse {
|
||||
checkoutEmailUpdate?: {
|
||||
checkout?: Checkout;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface MetadataUpdateResponse {
|
||||
updateMetadata?: {
|
||||
item?: {
|
||||
id: string;
|
||||
metadata?: Array<{ key: string; value: string }>;
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface ShippingMethodUpdateResponse {
|
||||
checkoutShippingMethodUpdate?: {
|
||||
checkout?: Checkout;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckoutQueryResponse {
|
||||
checkout?: Checkout;
|
||||
}
|
||||
@@ -96,7 +56,7 @@ export default function CheckoutPage() {
|
||||
const t = useTranslations("Checkout");
|
||||
const locale = useLocale();
|
||||
const router = useRouter();
|
||||
const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore();
|
||||
const { checkout, refreshCheckout, clearCheckout, getLines, getTotal } = useSaleorCheckoutStore();
|
||||
const { trackCheckoutStarted, trackCheckoutStep, trackOrderCompleted, identifyUser } = useAnalytics();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -104,6 +64,7 @@ export default function CheckoutPage() {
|
||||
const [orderNumber, setOrderNumber] = useState<string | null>(null);
|
||||
|
||||
const [sameAsShipping, setSameAsShipping] = useState(true);
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>(DEFAULT_PAYMENT_METHOD);
|
||||
const [shippingAddress, setShippingAddress] = useState<AddressForm>({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
@@ -129,11 +90,78 @@ export default function CheckoutPage() {
|
||||
|
||||
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
|
||||
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
|
||||
const [showShippingMethods, setShowShippingMethods] = useState(false);
|
||||
const [isLoadingShipping, setIsLoadingShipping] = useState(false);
|
||||
|
||||
const lines = getLines();
|
||||
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(() => {
|
||||
if (!checkout) {
|
||||
refreshCheckout();
|
||||
@@ -189,197 +217,121 @@ export default function CheckoutPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate all required fields
|
||||
if (!shippingAddress.email || !shippingAddress.email.includes("@")) {
|
||||
setError(t("errorEmailRequired"));
|
||||
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"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedShippingMethod) {
|
||||
setError(t("errorSelectShipping"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedPaymentMethod) {
|
||||
setError(t("errorSelectPayment"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// If we're showing shipping methods and one is selected, complete the order
|
||||
if (showShippingMethods && selectedShippingMethod) {
|
||||
console.log("Phase 2: Completing order with shipping method...");
|
||||
console.log("Completing order via CheckoutService...");
|
||||
|
||||
console.log("Step 1: Updating billing address...");
|
||||
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
||||
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
billingAddress: {
|
||||
firstName: billingAddress.firstName,
|
||||
lastName: billingAddress.lastName,
|
||||
streetAddress1: billingAddress.streetAddress1,
|
||||
streetAddress2: billingAddress.streetAddress2,
|
||||
city: billingAddress.city,
|
||||
postalCode: billingAddress.postalCode,
|
||||
country: billingAddress.country,
|
||||
phone: billingAddress.phone,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Create checkout service instance
|
||||
const checkoutService = createCheckoutService(checkout.id);
|
||||
|
||||
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
|
||||
throw new Error(`Billing address update failed: ${billingResult.data.checkoutBillingAddressUpdate.errors[0].message}`);
|
||||
}
|
||||
console.log("Step 1: Billing address updated successfully");
|
||||
// Transform form data to service types
|
||||
const serviceShippingAddress: Address = {
|
||||
firstName: shippingAddress.firstName,
|
||||
lastName: shippingAddress.lastName,
|
||||
streetAddress1: shippingAddress.streetAddress1,
|
||||
streetAddress2: shippingAddress.streetAddress2,
|
||||
city: shippingAddress.city,
|
||||
postalCode: shippingAddress.postalCode,
|
||||
country: shippingAddress.country,
|
||||
phone: shippingAddress.phone,
|
||||
};
|
||||
|
||||
console.log("Step 2: Setting shipping method...");
|
||||
const shippingMethodResult = await saleorClient.mutate<ShippingMethodUpdateResponse>({
|
||||
mutation: CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
shippingMethodId: selectedShippingMethod,
|
||||
},
|
||||
});
|
||||
const serviceBillingAddress: Address = {
|
||||
firstName: billingAddress.firstName,
|
||||
lastName: billingAddress.lastName,
|
||||
streetAddress1: billingAddress.streetAddress1,
|
||||
streetAddress2: billingAddress.streetAddress2,
|
||||
city: billingAddress.city,
|
||||
postalCode: billingAddress.postalCode,
|
||||
country: billingAddress.country,
|
||||
phone: billingAddress.phone,
|
||||
};
|
||||
|
||||
if (shippingMethodResult.data?.checkoutShippingMethodUpdate?.errors && shippingMethodResult.data.checkoutShippingMethodUpdate.errors.length > 0) {
|
||||
throw new Error(`Shipping method update failed: ${shippingMethodResult.data.checkoutShippingMethodUpdate.errors[0].message}`);
|
||||
}
|
||||
console.log("Step 2: Shipping method set successfully");
|
||||
|
||||
console.log("Step 3: Saving phone number...");
|
||||
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
|
||||
mutation: CHECKOUT_METADATA_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
metadata: [
|
||||
{ key: "phone", value: shippingAddress.phone },
|
||||
{ key: "shippingPhone", value: shippingAddress.phone },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (metadataResult.data?.updateMetadata?.errors && metadataResult.data.updateMetadata.errors.length > 0) {
|
||||
console.warn("Failed to save phone metadata:", metadataResult.data.updateMetadata.errors);
|
||||
} else {
|
||||
console.log("Step 3: Phone number saved successfully");
|
||||
}
|
||||
|
||||
console.log("Step 4: Completing checkout...");
|
||||
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
|
||||
mutation: CHECKOUT_COMPLETE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (completeResult.data?.checkoutComplete?.errors && completeResult.data.checkoutComplete.errors.length > 0) {
|
||||
throw new Error(completeResult.data.checkoutComplete.errors[0].message);
|
||||
}
|
||||
|
||||
const order = completeResult.data?.checkoutComplete?.order;
|
||||
if (order) {
|
||||
setOrderNumber(order.number);
|
||||
setOrderComplete(true);
|
||||
|
||||
// Track order completion
|
||||
const lines = getLines();
|
||||
const total = getTotal();
|
||||
trackOrderCompleted({
|
||||
order_id: checkout.id,
|
||||
order_number: order.number,
|
||||
total,
|
||||
currency: "RSD",
|
||||
item_count: lines.reduce((sum, line) => sum + line.quantity, 0),
|
||||
shipping_cost: shippingMethods.find(m => m.id === selectedShippingMethod)?.price.amount,
|
||||
customer_email: shippingAddress.email,
|
||||
});
|
||||
|
||||
// Identify the user
|
||||
identifyUser({
|
||||
profileId: shippingAddress.email,
|
||||
email: shippingAddress.email,
|
||||
firstName: shippingAddress.firstName,
|
||||
lastName: shippingAddress.lastName,
|
||||
});
|
||||
} else {
|
||||
throw new Error(t("errorCreatingOrder"));
|
||||
}
|
||||
} else {
|
||||
// Phase 1: Update email and address, then fetch shipping methods
|
||||
console.log("Phase 1: Updating email and address...");
|
||||
|
||||
console.log("Step 1: Updating email...");
|
||||
const emailResult = await saleorClient.mutate<EmailUpdateResponse>({
|
||||
mutation: CHECKOUT_EMAIL_UPDATE,
|
||||
variables: {
|
||||
checkoutId: checkout.id,
|
||||
email: shippingAddress.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
|
||||
throw new Error(`Email update failed: ${emailResult.data.checkoutEmailUpdate.errors[0].message}`);
|
||||
}
|
||||
console.log("Step 1: Email updated successfully");
|
||||
|
||||
console.log("Step 2: Updating shipping address...");
|
||||
console.log("Shipping address data:", {
|
||||
firstName: shippingAddress.firstName,
|
||||
lastName: shippingAddress.lastName,
|
||||
streetAddress1: shippingAddress.streetAddress1,
|
||||
city: shippingAddress.city,
|
||||
postalCode: shippingAddress.postalCode,
|
||||
country: shippingAddress.country,
|
||||
// Execute checkout pipeline
|
||||
const result = await checkoutService.execute({
|
||||
email: shippingAddress.email,
|
||||
shippingAddress: serviceShippingAddress,
|
||||
billingAddress: serviceBillingAddress,
|
||||
shippingMethodId: selectedShippingMethod,
|
||||
languageCode: locale.toUpperCase(),
|
||||
metadata: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
shippingPhone: shippingAddress.phone,
|
||||
userLanguage: locale,
|
||||
userLocale: locale,
|
||||
},
|
||||
});
|
||||
|
||||
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
|
||||
throw new Error(`Shipping address update failed: ${shippingResult.data.checkoutShippingAddressUpdate.errors[0].message}`);
|
||||
if (!result.success || !result.order) {
|
||||
// Handle specific error types
|
||||
if (result.error === "CHECKOUT_EXPIRED") {
|
||||
console.error("Checkout not found, clearing cart...");
|
||||
localStorage.removeItem('cart');
|
||||
localStorage.removeItem('checkoutId');
|
||||
window.location.href = `/${locale}/products`;
|
||||
return;
|
||||
}
|
||||
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...");
|
||||
throw new Error(result.error || t("errorCreatingOrder"));
|
||||
}
|
||||
|
||||
// Success!
|
||||
setOrderNumber(result.order.number);
|
||||
setOrderComplete(true);
|
||||
|
||||
// Clear the checkout/cart from the store
|
||||
clearCheckout();
|
||||
|
||||
// Track order completion
|
||||
const lines = getLines();
|
||||
const total = getTotal();
|
||||
trackOrderCompleted({
|
||||
order_id: checkout.id,
|
||||
order_number: result.order.number,
|
||||
total,
|
||||
currency: "RSD",
|
||||
item_count: lines.reduce((sum, line) => sum + line.quantity, 0),
|
||||
shipping_cost: shippingMethods.find(m => m.id === selectedShippingMethod)?.price.amount,
|
||||
customer_email: shippingAddress.email,
|
||||
});
|
||||
|
||||
// Identify the user
|
||||
identifyUser({
|
||||
profileId: shippingAddress.email,
|
||||
email: shippingAddress.email,
|
||||
firstName: shippingAddress.firstName,
|
||||
lastName: shippingAddress.lastName,
|
||||
});
|
||||
|
||||
console.log("Order completed successfully:", result.order.number);
|
||||
|
||||
} catch (err: unknown) {
|
||||
console.error("Checkout error:", err);
|
||||
|
||||
@@ -595,9 +547,17 @@ export default function CheckoutPage() {
|
||||
</div>
|
||||
|
||||
{/* Shipping Method Selection */}
|
||||
{showShippingMethods && shippingMethods.length > 0 && (
|
||||
<div className="border-b border-border pb-6">
|
||||
<h2 className="text-xl font-serif mb-4">{t("shippingMethod")}</h2>
|
||||
<div className="border-b border-border pb-6">
|
||||
<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">
|
||||
{shippingMethods.map((method) => (
|
||||
<label
|
||||
@@ -625,18 +585,34 @@ export default function CheckoutPage() {
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{!selectedShippingMethod && (
|
||||
<p className="text-red-500 text-sm mt-2">{t("errorSelectShipping")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<p className="text-foreground-muted">{t("enterAddressForShipping")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Method Section */}
|
||||
<PaymentSection
|
||||
selectedMethodId={selectedPaymentMethod}
|
||||
onSelectMethod={setSelectedPaymentMethod}
|
||||
locale={locale}
|
||||
channel="default-channel"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{/* Money Back Guarantee Trust Badge */}
|
||||
<div className="flex items-center justify-center gap-2 py-3 px-4 bg-green-50 rounded-lg border border-green-100">
|
||||
<svg className="w-5 h-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-green-800">{t("moneyBackGuarantee")}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{isLoading ? t("processing") : showShippingMethods ? t("completeOrder", { total: formatPrice(total) }) : t("continueToShipping")}
|
||||
{isLoading ? t("processing") : t("completeOrder", { total: formatPrice(total) })}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -680,6 +656,12 @@ export default function CheckoutPage() {
|
||||
<span className="text-foreground-muted">{t("subtotal")}</span>
|
||||
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
|
||||
</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">
|
||||
<span>{t("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";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
@@ -30,14 +30,16 @@ export default function CartDrawer() {
|
||||
const lines = getLines();
|
||||
const total = getTotal();
|
||||
const lineCount = getLineCount();
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) {
|
||||
if (!initializedRef.current && locale) {
|
||||
// Set language code before initializing checkout
|
||||
useSaleorCheckoutStore.getState().setLanguageCode(locale);
|
||||
initCheckout();
|
||||
setInitialized(true);
|
||||
initializedRef.current = true;
|
||||
}
|
||||
}, [initialized]);
|
||||
}, [locale]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
|
||||
@@ -15,9 +15,13 @@ interface NewHeroProps {
|
||||
|
||||
export default function NewHero({ featuredProduct }: NewHeroProps) {
|
||||
const locale = useLocale();
|
||||
const { addLine, openCart } = useSaleorCheckoutStore();
|
||||
const { addLine, openCart, setLanguageCode } = useSaleorCheckoutStore();
|
||||
|
||||
const handleAddToCart = async () => {
|
||||
// Set language code before adding to cart
|
||||
if (locale) {
|
||||
setLanguageCode(locale);
|
||||
}
|
||||
const variant = featuredProduct?.variants?.[0];
|
||||
if (variant?.id) {
|
||||
await addLine(variant.id, 1);
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||
import { User, ShoppingBag, Menu, X, Globe } from "lucide-react";
|
||||
import CartDrawer from "@/components/cart/CartDrawer";
|
||||
@@ -16,14 +16,15 @@ interface HeaderProps {
|
||||
locale?: string;
|
||||
}
|
||||
|
||||
export default function Header({ locale = "sr" }: HeaderProps) {
|
||||
export default function Header({ locale: propLocale = "sr" }: HeaderProps) {
|
||||
const t = useTranslations("Header");
|
||||
const pathname = usePathname();
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [scrolled, setScrolled] = 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 currentLocale = isValidLocale(locale) ? LOCALE_CONFIG[locale] : LOCALE_CONFIG.sr;
|
||||
@@ -54,9 +55,14 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
||||
setLangDropdownOpen(false);
|
||||
};
|
||||
|
||||
// Set language code first, then initialize checkout
|
||||
useEffect(() => {
|
||||
initCheckout();
|
||||
}, [initCheckout]);
|
||||
if (locale) {
|
||||
setLanguageCode(locale);
|
||||
// Initialize checkout after language code is set
|
||||
initCheckout();
|
||||
}
|
||||
}, [locale, setLanguageCode, initCheckout]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
|
||||
6
src/components/payment/CODInstructions.tsx
Normal file
6
src/components/payment/CODInstructions.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
// COD Instructions component - currently disabled as the instructions are self-explanatory
|
||||
// Can be re-enabled if payment method instructions are needed in the future
|
||||
|
||||
export function CODInstructions() {
|
||||
return null;
|
||||
}
|
||||
125
src/components/payment/PaymentMethodCard.tsx
Normal file
125
src/components/payment/PaymentMethodCard.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PaymentMethod } from "@/lib/saleor/payments/types";
|
||||
import { Banknote, CreditCard, Building2, LucideIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// Icon mapping for payment methods
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
Banknote,
|
||||
CreditCard,
|
||||
Building2,
|
||||
};
|
||||
|
||||
interface PaymentMethodCardProps {
|
||||
method: PaymentMethod;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function PaymentMethodCard({
|
||||
method,
|
||||
isSelected,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
locale,
|
||||
}: PaymentMethodCardProps) {
|
||||
const t = useTranslations("Payment");
|
||||
const Icon = method.icon ? iconMap[method.icon] : Banknote;
|
||||
|
||||
// Get translated name and description based on method ID
|
||||
const translatedName = t(`${method.id}.name`);
|
||||
const translatedDescription = t(`${method.id}.description`);
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"relative flex cursor-pointer items-start gap-4 rounded-xl border-2 p-5 transition-all duration-300",
|
||||
"hover:scale-[1.02] hover:shadow-lg",
|
||||
isSelected
|
||||
? "border-[#059669] bg-white shadow-xl shadow-[#047857]/30"
|
||||
: "border-gray-200 bg-white hover:border-[#3B82F6]",
|
||||
(disabled || !method.available) && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="payment-method"
|
||||
value={method.id}
|
||||
checked={isSelected}
|
||||
onChange={onSelect}
|
||||
disabled={disabled || !method.available}
|
||||
className="sr-only"
|
||||
/>
|
||||
|
||||
{/* Glowing green checkmark for selected */}
|
||||
{isSelected && (
|
||||
<div className="absolute -right-2 -top-2 z-10">
|
||||
<div className="relative">
|
||||
{/* Glow effect */}
|
||||
<div className="absolute inset-0 rounded-full bg-[#059669] blur-md opacity-70" />
|
||||
{/* Green circle with checkmark */}
|
||||
<div className="relative flex h-8 w-8 items-center justify-center rounded-full bg-gradient-to-br from-[#059669] to-[#047857] shadow-lg">
|
||||
<svg
|
||||
className="h-5 w-5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn(
|
||||
"flex h-12 w-12 shrink-0 items-center justify-center rounded-xl transition-all duration-300",
|
||||
isSelected
|
||||
? "bg-gradient-to-br from-[#059669] to-[#047857] shadow-lg shadow-[#047857]/40"
|
||||
: "bg-gradient-to-br from-blue-50 to-blue-100"
|
||||
)}>
|
||||
<Icon className={cn(
|
||||
"h-6 w-6 transition-colors",
|
||||
isSelected ? "text-white" : "text-[#3B82F6]"
|
||||
)} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 pr-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={cn(
|
||||
"text-lg font-bold transition-colors",
|
||||
isSelected ? "text-[#047857]" : "text-gray-900"
|
||||
)}>
|
||||
{translatedName}
|
||||
</span>
|
||||
{method.fee > 0 && (
|
||||
<span className="text-sm font-semibold text-amber-600 bg-amber-100 px-2 py-1 rounded-full">
|
||||
+{new Intl.NumberFormat(locale === 'sr' ? 'sr-RS' : 'en-US', {
|
||||
style: 'currency',
|
||||
currency: 'RSD',
|
||||
}).format(method.fee)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={cn(
|
||||
"mt-1 text-sm font-medium transition-colors",
|
||||
isSelected ? "text-gray-700" : "text-gray-600"
|
||||
)}>
|
||||
{translatedDescription}
|
||||
</p>
|
||||
|
||||
{!method.available && (
|
||||
<span className="mt-2 inline-block text-xs font-medium text-gray-500 bg-gray-100 px-2 py-1 rounded">
|
||||
{t(`${method.id}.comingSoon`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
62
src/components/payment/PaymentMethodSelector.tsx
Normal file
62
src/components/payment/PaymentMethodSelector.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import type { PaymentMethod } from "@/lib/saleor/payments/types";
|
||||
import { PaymentMethodCard } from "./PaymentMethodCard";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface PaymentMethodSelectorProps {
|
||||
methods: PaymentMethod[];
|
||||
selectedMethodId: string;
|
||||
onSelectMethod: (methodId: string) => void;
|
||||
locale: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function PaymentMethodSelector({
|
||||
methods,
|
||||
selectedMethodId,
|
||||
onSelectMethod,
|
||||
locale,
|
||||
disabled = false,
|
||||
}: PaymentMethodSelectorProps) {
|
||||
const t = useTranslations("Payment");
|
||||
|
||||
// Filter to only available methods
|
||||
const availableMethods = methods.filter((m) => m.available);
|
||||
|
||||
if (availableMethods.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 p-4 text-center text-gray-500">
|
||||
{t("noMethodsAvailable")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// If only one method, show it as selected but don't allow changing
|
||||
const isSingleMethod = availableMethods.length === 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-medium">{t("title")}</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{availableMethods.map((method) => (
|
||||
<PaymentMethodCard
|
||||
key={method.id}
|
||||
method={method}
|
||||
isSelected={selectedMethodId === method.id}
|
||||
onSelect={() => onSelectMethod(method.id)}
|
||||
disabled={disabled || isSingleMethod}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isSingleMethod && (
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("singleMethodNotice")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
src/components/payment/index.ts
Normal file
4
src/components/payment/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Payment components exports
|
||||
export { PaymentMethodSelector } from "./PaymentMethodSelector";
|
||||
export { PaymentMethodCard } from "./PaymentMethodCard";
|
||||
export { CODInstructions } from "./CODInstructions";
|
||||
@@ -99,7 +99,7 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [urgencyIndex, setUrgencyIndex] = useState(0);
|
||||
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
|
||||
const { addLine, openCart } = useSaleorCheckoutStore();
|
||||
const { addLine, openCart, setLanguageCode } = useSaleorCheckoutStore();
|
||||
const { trackProductView, trackAddToCart } = useAnalytics();
|
||||
const validLocale = isValidLocale(locale) ? locale : "sr";
|
||||
|
||||
@@ -147,6 +147,11 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
|
||||
const handleAddToCart = async () => {
|
||||
if (!selectedVariantId) return;
|
||||
|
||||
// Set language code before adding to cart
|
||||
if (validLocale) {
|
||||
setLanguageCode(validLocale);
|
||||
}
|
||||
|
||||
setIsAdding(true);
|
||||
try {
|
||||
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 support@manoonoils.com",
|
||||
},
|
||||
en: {
|
||||
title: "Your Order Has Been Cancelled",
|
||||
preview: "Your order has been cancelled",
|
||||
greeting: "Dear {name},",
|
||||
orderCancelled:
|
||||
"Your order has been cancelled. If you did not request this cancellation, please contact us as soon as possible.",
|
||||
items: "Items",
|
||||
total: "Total",
|
||||
reason: "Reason",
|
||||
questions: "Questions? Email us at support@manoonoils.com",
|
||||
},
|
||||
de: {
|
||||
title: "Ihre Bestellung wurde storniert",
|
||||
preview: "Ihre Bestellung wurde storniert",
|
||||
greeting: "Sehr geehrte/r {name},",
|
||||
orderCancelled:
|
||||
"Ihre Bestellung wurde storniert. Wenn Sie diese Stornierung nicht angefordert haben, kontaktieren Sie uns bitte so schnell wie möglich.",
|
||||
items: "Artikel",
|
||||
total: "Gesamt",
|
||||
reason: "Grund",
|
||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||
},
|
||||
fr: {
|
||||
title: "Votre commande a été annulée",
|
||||
preview: "Votre commande a été annulée",
|
||||
greeting: "Cher(e) {name},",
|
||||
orderCancelled:
|
||||
"Votre commande a été annulée. Si vous n'avez pas demandé cette annulation, veuillez nous contacter dès que possible.",
|
||||
items: "Articles",
|
||||
total: "Total",
|
||||
reason: "Raison",
|
||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderCancelled({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerName,
|
||||
items,
|
||||
total,
|
||||
reason,
|
||||
siteUrl,
|
||||
}: OrderCancelledProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||
<Text style={styles.title}>{t.title}</Text>
|
||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||
<Text style={styles.text}>{t.orderCancelled}</Text>
|
||||
|
||||
<Section style={styles.orderInfo}>
|
||||
<Text style={styles.orderNumber}>
|
||||
<strong>Order Number:</strong> {orderNumber}
|
||||
</Text>
|
||||
{reason && (
|
||||
<Text style={styles.reason}>
|
||||
<strong>{t.reason}:</strong> {reason}
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{t.items}</Text>
|
||||
<Hr style={styles.hr} />
|
||||
{items.map((item) => (
|
||||
<Section key={item.id} style={styles.itemRow}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.quantity}x {item.name}
|
||||
</Text>
|
||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||
</Section>
|
||||
))}
|
||||
<Hr style={styles.hr} />
|
||||
<Section style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>{t.total}:</Text>
|
||||
<Text style={styles.totalValue}>{total}</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={siteUrl} style={styles.button}>
|
||||
{language === "sr" ? "Pogledajte proizvode" : "Browse Products"}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={styles.questions}>{t.questions}</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#dc2626",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
greeting: {
|
||||
fontSize: "16px",
|
||||
color: "#333333",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
text: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderInfo: {
|
||||
backgroundColor: "#fef2f2",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderNumber: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0 0 5px 0",
|
||||
},
|
||||
reason: {
|
||||
fontSize: "14px",
|
||||
color: "#991b1b",
|
||||
margin: "0",
|
||||
},
|
||||
itemsSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e0e0e0",
|
||||
margin: "10px 0",
|
||||
},
|
||||
itemRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
itemName: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
itemPrice: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
totalRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
buttonSection: {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "20px",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "12px 30px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
textDecoration: "none",
|
||||
},
|
||||
questions: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
},
|
||||
};
|
||||
@@ -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 support@manoonoils.com",
|
||||
thankYou: "Hvala Vam što kupujete kod nas!",
|
||||
adminTitle: "Nova narudžbina!",
|
||||
adminPreview: "Nova narudžbina je primljena",
|
||||
adminGreeting: "Čestitamo na prodaji!",
|
||||
adminMessage: "Nova narudžbina je upravo primljena. Detalji su ispod:",
|
||||
customerLabel: "Kupac",
|
||||
customerEmailLabel: "Email kupca",
|
||||
billingAddressLabel: "Adresa za naplatu",
|
||||
phoneLabel: "Telefon",
|
||||
viewDashboard: "Pogledaj u Dashboardu",
|
||||
},
|
||||
en: {
|
||||
title: "Order Confirmation",
|
||||
preview: "Your order has been confirmed",
|
||||
greeting: "Dear {name},",
|
||||
orderReceived:
|
||||
"Thank you for your order! We have received it and it is now being processed.",
|
||||
orderNumber: "Order number",
|
||||
items: "Items",
|
||||
quantity: "Quantity",
|
||||
total: "Total",
|
||||
shippingTo: "Shipping address",
|
||||
questions: "Questions? Email us at support@manoonoils.com",
|
||||
thankYou: "Thank you for shopping with us!",
|
||||
adminTitle: "New Order! 🎉",
|
||||
adminPreview: "A new order has been received",
|
||||
adminGreeting: "Congratulations on the sale!",
|
||||
adminMessage: "A new order has just been placed. Details below:",
|
||||
customerLabel: "Customer",
|
||||
customerEmailLabel: "Customer Email",
|
||||
billingAddressLabel: "Billing Address",
|
||||
phoneLabel: "Phone",
|
||||
viewDashboard: "View in Dashboard",
|
||||
},
|
||||
de: {
|
||||
title: "Bestellungsbestätigung",
|
||||
preview: "Ihre Bestellung wurde bestätigt",
|
||||
greeting: "Sehr geehrte/r {name},",
|
||||
orderReceived:
|
||||
"Vielen Dank für Ihre Bestellung! Wir haben sie erhalten und sie wird nun bearbeitet.",
|
||||
orderNumber: "Bestellnummer",
|
||||
items: "Artikel",
|
||||
quantity: "Menge",
|
||||
total: "Gesamt",
|
||||
shippingTo: "Lieferadresse",
|
||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||
thankYou: "Vielen Dank für Ihren Einkauf!",
|
||||
adminTitle: "Neue Bestellung! 🎉",
|
||||
adminPreview: "Eine neue Bestellung wurde erhalten",
|
||||
adminGreeting: "Glückwunsch zum Verkauf!",
|
||||
adminMessage: "Eine neue Bestellung wurde soeben aufgegeben. Details unten:",
|
||||
customerLabel: "Kunde",
|
||||
customerEmailLabel: "Kunden-E-Mail",
|
||||
billingAddressLabel: "Rechnungsadresse",
|
||||
phoneLabel: "Telefon",
|
||||
viewDashboard: "Im Dashboard anzeigen",
|
||||
},
|
||||
fr: {
|
||||
title: "Confirmation de commande",
|
||||
preview: "Votre commande a été confirmée",
|
||||
greeting: "Cher(e) {name},",
|
||||
orderReceived:
|
||||
"Merci pour votre commande! Nous l'avons reçue et elle est en cours de traitement.",
|
||||
orderNumber: "Numéro de commande",
|
||||
items: "Articles",
|
||||
quantity: "Quantité",
|
||||
total: "Total",
|
||||
shippingTo: "Adresse de livraison",
|
||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||
thankYou: "Merci d'avoir Magasiné avec nous!",
|
||||
adminTitle: "Nouvelle commande! 🎉",
|
||||
adminPreview: "Une nouvelle commande a été reçue",
|
||||
adminGreeting: "Félicitations pour la vente!",
|
||||
adminMessage: "Une nouvelle commande vient d'être passée. Détails ci-dessous:",
|
||||
customerLabel: "Client",
|
||||
customerEmailLabel: "Email du client",
|
||||
billingAddressLabel: "Adresse de facturation",
|
||||
phoneLabel: "Téléphone",
|
||||
viewDashboard: "Voir dans le Dashboard",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderConfirmation({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerEmail,
|
||||
customerName,
|
||||
items,
|
||||
total,
|
||||
shippingAddress,
|
||||
billingAddress,
|
||||
phone,
|
||||
siteUrl,
|
||||
dashboardUrl,
|
||||
isAdmin = false,
|
||||
}: OrderConfirmationProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
// For admin emails, always use English
|
||||
const adminT = translations["en"];
|
||||
|
||||
if (isAdmin) {
|
||||
return (
|
||||
<BaseLayout previewText={adminT.adminPreview} language="en" siteUrl={siteUrl}>
|
||||
<Text style={styles.title}>{adminT.adminTitle}</Text>
|
||||
<Text style={styles.greeting}>{adminT.adminGreeting}</Text>
|
||||
<Text style={styles.text}>{adminT.adminMessage}</Text>
|
||||
|
||||
<Section style={styles.orderInfo}>
|
||||
<Text style={styles.orderNumber}>
|
||||
<strong>{adminT.orderNumber}:</strong> {orderNumber}
|
||||
</Text>
|
||||
<Text style={styles.customerInfo}>
|
||||
<strong>{adminT.customerLabel}:</strong> {customerName}
|
||||
</Text>
|
||||
<Text style={styles.customerInfo}>
|
||||
<strong>{adminT.customerEmailLabel}:</strong> {customerEmail}
|
||||
</Text>
|
||||
{phone && (
|
||||
<Text style={styles.customerInfo}>
|
||||
<strong>{adminT.phoneLabel}:</strong> {phone}
|
||||
</Text>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{adminT.items}</Text>
|
||||
<Hr style={styles.hr} />
|
||||
{items.map((item) => (
|
||||
<Section key={item.id} style={styles.itemRow}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.quantity}x {item.name}
|
||||
</Text>
|
||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||
</Section>
|
||||
))}
|
||||
<Hr style={styles.hr} />
|
||||
<Section style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>{adminT.total}:</Text>
|
||||
<Text style={styles.totalValue}>{total}</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
{shippingAddress && (
|
||||
<Section style={styles.shippingSection}>
|
||||
<Text style={styles.sectionTitle}>{adminT.shippingTo}</Text>
|
||||
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{billingAddress && (
|
||||
<Section style={styles.shippingSection}>
|
||||
<Text style={styles.sectionTitle}>{adminT.billingAddressLabel}</Text>
|
||||
<Text style={styles.shippingAddress}>{billingAddress}</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={`${dashboardUrl}/orders/${orderId}`} style={styles.button}>
|
||||
{adminT.viewDashboard}
|
||||
</Button>
|
||||
</Section>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||
<Text style={styles.title}>{t.title}</Text>
|
||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||
<Text style={styles.text}>{t.orderReceived}</Text>
|
||||
|
||||
<Section style={styles.orderInfo}>
|
||||
<Text style={styles.orderNumber}>
|
||||
<strong>{t.orderNumber}:</strong> {orderNumber}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{t.items}</Text>
|
||||
<Hr style={styles.hr} />
|
||||
{items.map((item) => (
|
||||
<Section key={item.id} style={styles.itemRow}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.quantity}x {item.name}
|
||||
</Text>
|
||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||
</Section>
|
||||
))}
|
||||
<Hr style={styles.hr} />
|
||||
<Section style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>{t.total}:</Text>
|
||||
<Text style={styles.totalValue}>{total}</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
{shippingAddress && (
|
||||
<Section style={styles.shippingSection}>
|
||||
<Text style={styles.sectionTitle}>{t.shippingTo}</Text>
|
||||
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={siteUrl} style={styles.button}>
|
||||
{language === "sr"
|
||||
? "Pogledajte narudžbinu"
|
||||
: language === "de"
|
||||
? "Bestellung ansehen"
|
||||
: language === "fr"
|
||||
? "Voir la commande"
|
||||
: "View Order"}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={styles.questions}>{t.questions}</Text>
|
||||
<Text style={styles.thankYou}>{t.thankYou}</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
greeting: {
|
||||
fontSize: "16px",
|
||||
color: "#333333",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
text: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderInfo: {
|
||||
backgroundColor: "#f9f9f9",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderNumber: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0 0 8px 0",
|
||||
},
|
||||
customerInfo: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0 0 4px 0",
|
||||
},
|
||||
itemsSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e0e0e0",
|
||||
margin: "10px 0",
|
||||
},
|
||||
itemRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
itemName: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
itemPrice: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
totalRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
margin: "0",
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
margin: "0",
|
||||
},
|
||||
shippingSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
shippingAddress: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
},
|
||||
buttonSection: {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "20px",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "12px 30px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
textDecoration: "none",
|
||||
},
|
||||
questions: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
thankYou: {
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
},
|
||||
};
|
||||
@@ -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 support@manoonoils.com",
|
||||
},
|
||||
en: {
|
||||
title: "Payment Received!",
|
||||
preview: "Your payment has been recorded",
|
||||
greeting: "Dear {name},",
|
||||
orderPaid:
|
||||
"Payment for your order has been received. Thank you! Your order will be prepared for shipping soon.",
|
||||
items: "Items",
|
||||
total: "Total",
|
||||
nextSteps: "What's next?",
|
||||
nextStepsText:
|
||||
"You will receive another email when your order ships. You can expect delivery within 3-5 business days.",
|
||||
questions: "Questions? Email us at support@manoonoils.com",
|
||||
},
|
||||
de: {
|
||||
title: "Zahlung erhalten!",
|
||||
preview: "Ihre Zahlung wurde verbucht",
|
||||
greeting: "Sehr geehrte/r {name},",
|
||||
orderPaid:
|
||||
"Zahlung für Ihre Bestellung ist eingegangen. Vielen Dank! Ihre Bestellung wird bald für den Versand vorbereitet.",
|
||||
items: "Artikel",
|
||||
total: "Gesamt",
|
||||
nextSteps: "Was kommt als nächstes?",
|
||||
nextStepsText:
|
||||
"Sie erhalten eine weitere E-Mail, wenn Ihre Bestellung versandt wird. Die Lieferung erfolgt innerhalb von 3-5 Werktagen.",
|
||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||
},
|
||||
fr: {
|
||||
title: "Paiement reçu!",
|
||||
preview: "Votre paiement a été enregistré",
|
||||
greeting: "Cher(e) {name},",
|
||||
orderPaid:
|
||||
"Le paiement de votre commande a été reçu. Merci! Votre commande sera bientôt prête à être expédiée.",
|
||||
items: "Articles",
|
||||
total: "Total",
|
||||
nextSteps: "Et ensuite?",
|
||||
nextStepsText:
|
||||
"Vous recevrez un autre email lorsque votre commande sera expédiée. Vous pouvez vous attendre à une livraison dans 3-5 jours ouvrables.",
|
||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderPaid({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerName,
|
||||
items,
|
||||
total,
|
||||
siteUrl,
|
||||
}: OrderPaidProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||
<Text style={styles.title}>{t.title}</Text>
|
||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||
<Text style={styles.text}>{t.orderPaid}</Text>
|
||||
|
||||
<Section style={styles.orderInfo}>
|
||||
<Text style={styles.orderNumber}>
|
||||
<strong>Order Number:</strong> {orderNumber}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{t.items}</Text>
|
||||
<Hr style={styles.hr} />
|
||||
{items.map((item) => (
|
||||
<Section key={item.id} style={styles.itemRow}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.quantity}x {item.name}
|
||||
</Text>
|
||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||
</Section>
|
||||
))}
|
||||
<Hr style={styles.hr} />
|
||||
<Section style={styles.totalRow}>
|
||||
<Text style={styles.totalLabel}>{t.total}:</Text>
|
||||
<Text style={styles.totalValue}>{total}</Text>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.nextSteps}>
|
||||
<Text style={styles.nextStepsTitle}>{t.nextSteps}</Text>
|
||||
<Text style={styles.nextStepsText}>{t.nextStepsText}</Text>
|
||||
</Section>
|
||||
|
||||
<Section style={styles.buttonSection}>
|
||||
<Button href={siteUrl} style={styles.button}>
|
||||
{language === "sr" ? "Nastavite kupovinu" : "Continue Shopping"}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={styles.questions}>{t.questions}</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#16a34a",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
greeting: {
|
||||
fontSize: "16px",
|
||||
color: "#333333",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
text: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderInfo: {
|
||||
backgroundColor: "#f0fdf4",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
orderNumber: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
itemsSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e0e0e0",
|
||||
margin: "10px 0",
|
||||
},
|
||||
itemRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
itemName: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
itemPrice: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
totalRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
totalLabel: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
margin: "0",
|
||||
},
|
||||
totalValue: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
margin: "0",
|
||||
},
|
||||
nextSteps: {
|
||||
backgroundColor: "#f9f9f9",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
nextStepsTitle: {
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "5px",
|
||||
},
|
||||
nextStepsText: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
margin: "0",
|
||||
},
|
||||
buttonSection: {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "20px",
|
||||
},
|
||||
button: {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "12px 30px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold" as const,
|
||||
textDecoration: "none",
|
||||
},
|
||||
questions: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
},
|
||||
};
|
||||
@@ -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 support@manoonoils.com",
|
||||
},
|
||||
en: {
|
||||
title: "Your Order Has Shipped!",
|
||||
preview: "Your order is on its way",
|
||||
greeting: "Dear {name},",
|
||||
orderShipped:
|
||||
"Great news! Your order has been shipped and will arrive at your address soon.",
|
||||
tracking: "Track your shipment",
|
||||
items: "Items",
|
||||
questions: "Questions? Email us at support@manoonoils.com",
|
||||
},
|
||||
de: {
|
||||
title: "Ihre Bestellung wurde versendet!",
|
||||
preview: "Ihre Bestellung ist unterwegs",
|
||||
greeting: "Sehr geehrte/r {name},",
|
||||
orderShipped:
|
||||
"Großartige Neuigkeiten! Ihre Bestellung wurde versandt und wird in Kürze bei Ihnen eintreffen.",
|
||||
tracking: "Sendung verfolgen",
|
||||
items: "Artikel",
|
||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
||||
},
|
||||
fr: {
|
||||
title: "Votre commande a été expédiée!",
|
||||
preview: "Votre commande est en route",
|
||||
greeting: "Cher(e) {name},",
|
||||
orderShipped:
|
||||
"Bonne nouvelle! Votre commande a été expédiée et arrivera bientôt à votre adresse.",
|
||||
tracking: "Suivre votre envoi",
|
||||
items: "Articles",
|
||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
||||
},
|
||||
};
|
||||
|
||||
export function OrderShipped({
|
||||
language = "en",
|
||||
orderId,
|
||||
orderNumber,
|
||||
customerName,
|
||||
items,
|
||||
trackingNumber,
|
||||
trackingUrl,
|
||||
siteUrl,
|
||||
}: OrderShippedProps) {
|
||||
const t = translations[language] || translations.en;
|
||||
|
||||
return (
|
||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
||||
<Text style={styles.title}>{t.title}</Text>
|
||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
||||
<Text style={styles.text}>{t.orderShipped}</Text>
|
||||
|
||||
{trackingNumber && (
|
||||
<Section style={styles.trackingSection}>
|
||||
<Text style={styles.sectionTitle}>{t.tracking}</Text>
|
||||
{trackingUrl ? (
|
||||
<Button href={trackingUrl} style={styles.trackingButton}>
|
||||
{trackingNumber}
|
||||
</Button>
|
||||
) : (
|
||||
<Text style={styles.trackingNumber}>{trackingNumber}</Text>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section style={styles.itemsSection}>
|
||||
<Text style={styles.sectionTitle}>{t.items}</Text>
|
||||
<Hr style={styles.hr} />
|
||||
{items.map((item) => (
|
||||
<Section key={item.id} style={styles.itemRow}>
|
||||
<Text style={styles.itemName}>
|
||||
{item.quantity}x {item.name}
|
||||
</Text>
|
||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
||||
</Section>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Text style={styles.questions}>{t.questions}</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
title: {
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
greeting: {
|
||||
fontSize: "16px",
|
||||
color: "#333333",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
text: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
trackingSection: {
|
||||
backgroundColor: "#f9f9f9",
|
||||
padding: "15px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "20px",
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold" as const,
|
||||
color: "#1a1a1a",
|
||||
marginBottom: "10px",
|
||||
},
|
||||
trackingNumber: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
trackingButton: {
|
||||
backgroundColor: "#000000",
|
||||
color: "#ffffff",
|
||||
padding: "10px 20px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "14px",
|
||||
textDecoration: "none",
|
||||
},
|
||||
itemsSection: {
|
||||
marginBottom: "20px",
|
||||
},
|
||||
hr: {
|
||||
borderColor: "#e0e0e0",
|
||||
margin: "10px 0",
|
||||
},
|
||||
itemRow: {
|
||||
display: "flex" as const,
|
||||
justifyContent: "space-between" as const,
|
||||
padding: "8px 0",
|
||||
},
|
||||
itemName: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
itemPrice: {
|
||||
fontSize: "14px",
|
||||
color: "#333333",
|
||||
margin: "0",
|
||||
},
|
||||
questions: {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
},
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export { BaseLayout } from "./BaseLayout";
|
||||
export { OrderConfirmation } from "./OrderConfirmation";
|
||||
export { OrderShipped } from "./OrderShipped";
|
||||
export { OrderCancelled } from "./OrderCancelled";
|
||||
export { OrderPaid } from "./OrderPaid";
|
||||
@@ -361,6 +361,7 @@
|
||||
"cashOnDeliveryDesc": "Bezahlen Sie, wenn Ihre Bestellung an Ihre Tür geliefert wird.",
|
||||
"processing": "Wird bearbeitet...",
|
||||
"completeOrder": "Bestellung abschließen - {total}",
|
||||
"moneyBackGuarantee": "30 Tage Geld-zurück-Garantie",
|
||||
"orderSummary": "Bestellübersicht",
|
||||
"qty": "Menge",
|
||||
"subtotal": "Zwischensumme",
|
||||
@@ -372,12 +373,49 @@
|
||||
"errorNoCheckout": "Keine aktive Kasse. Bitte versuchen Sie es erneut.",
|
||||
"errorEmailRequired": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
|
||||
"errorFieldsRequired": "Bitte füllen Sie alle erforderlichen Felder aus.",
|
||||
"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.",
|
||||
"errorCreatingOrder": "Bestellung konnte nicht erstellt werden.",
|
||||
"orderConfirmed": "Bestellung bestätigt!",
|
||||
"thankYou": "Vielen Dank für Ihren Einkauf.",
|
||||
"orderNumber": "Bestellnummer",
|
||||
"confirmationEmail": "Sie erhalten in Kürze eine Bestätigungs-E-Mail. Wir werden Sie kontaktieren, um Nachnahme zu arrangieren.",
|
||||
"continueShoppingBtn": "Weiter einkaufen"
|
||||
"continueShoppingBtn": "Weiter einkaufen",
|
||||
"errorSelectPayment": "Bitte wählen Sie eine Zahlungsmethode."
|
||||
},
|
||||
"Payment": {
|
||||
"title": "Zahlungsmethode",
|
||||
"selectMethod": "Zahlungsmethode wählen",
|
||||
"securePayment": "Sichere Zahlungsabwicklung",
|
||||
"noMethodsAvailable": "Keine Zahlungsmethoden verfügbar",
|
||||
"singleMethodNotice": "Nachnahme ist die einzige verfügbare Zahlungsmethode für Ihren Standort",
|
||||
"selected": "Ausgewählt",
|
||||
"cod": {
|
||||
"name": "Nachnahme",
|
||||
"description": "Bezahlen Sie bei Erhalt Ihrer Bestellung",
|
||||
"instructions": {
|
||||
"title": "Zahlungsanweisungen",
|
||||
"prepareCash": "Bargeld vorbereiten",
|
||||
"prepareCashDesc": "Bitte haben Sie den genauen Betrag in bar bereit",
|
||||
"inspectOrder": "Vor Zahlung prüfen",
|
||||
"inspectOrderDesc": "Sie können Ihre Bestellung vor der Zahlung überprüfen",
|
||||
"noFee": "Keine zusätzliche Gebühr",
|
||||
"noFeeDesc": "Nachnahme ist völlig kostenlos"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"name": "Kreditkarte",
|
||||
"description": "Sichere Online-Zahlung",
|
||||
"comingSoon": "Demnächst verfügbar"
|
||||
},
|
||||
"bank_transfer": {
|
||||
"name": "Banküberweisung",
|
||||
"description": "Bezahlen Sie per Banküberweisung",
|
||||
"comingSoon": "Demnächst verfügbar"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,6 +407,7 @@
|
||||
"cashOnDeliveryDesc": "Pay when your order is delivered to your door.",
|
||||
"processing": "Processing...",
|
||||
"completeOrder": "Complete Order - {total}",
|
||||
"moneyBackGuarantee": "30-Day Money-Back Guarantee",
|
||||
"orderSummary": "Order Summary",
|
||||
"qty": "Qty",
|
||||
"subtotal": "Subtotal",
|
||||
@@ -420,6 +421,9 @@
|
||||
"errorFieldsRequired": "Please fill in all required fields.",
|
||||
"errorNoShippingMethods": "No shipping methods available for this address. Please check your address or contact support.",
|
||||
"errorSelectShipping": "Please select a shipping method.",
|
||||
"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.",
|
||||
"errorCreatingOrder": "Failed to create order.",
|
||||
"continueToShipping": "Continue to Shipping",
|
||||
@@ -427,6 +431,38 @@
|
||||
"thankYou": "Thank you for your purchase.",
|
||||
"orderNumber": "Order Number",
|
||||
"confirmationEmail": "You will receive a confirmation email shortly. We will contact you to arrange Cash on Delivery.",
|
||||
"continueShoppingBtn": "Continue Shopping"
|
||||
"continueShoppingBtn": "Continue Shopping",
|
||||
"errorSelectPayment": "Please select a payment method."
|
||||
},
|
||||
"Payment": {
|
||||
"title": "Payment Method",
|
||||
"selectMethod": "Select payment method",
|
||||
"securePayment": "Secure payment processing",
|
||||
"noMethodsAvailable": "No payment methods available",
|
||||
"singleMethodNotice": "Cash on Delivery is the only available payment method for your location",
|
||||
"selected": "Selected",
|
||||
"cod": {
|
||||
"name": "Cash on Delivery",
|
||||
"description": "Pay when you receive your order",
|
||||
"instructions": {
|
||||
"title": "Payment Instructions",
|
||||
"prepareCash": "Prepare Cash",
|
||||
"prepareCashDesc": "Please have the exact amount ready in cash",
|
||||
"inspectOrder": "Inspect Before Paying",
|
||||
"inspectOrderDesc": "You can check your order before making payment",
|
||||
"noFee": "No Extra Fee",
|
||||
"noFeeDesc": "Cash on Delivery is completely free"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"name": "Credit Card",
|
||||
"description": "Secure online payment",
|
||||
"comingSoon": "Coming soon"
|
||||
},
|
||||
"bank_transfer": {
|
||||
"name": "Bank Transfer",
|
||||
"description": "Pay via bank transfer",
|
||||
"comingSoon": "Coming soon"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,6 +361,7 @@
|
||||
"cashOnDeliveryDesc": "Payez lorsque votre commande est livrée à votre porte.",
|
||||
"processing": "En cours...",
|
||||
"completeOrder": "Finaliser la Commande - {total}",
|
||||
"moneyBackGuarantee": "Garantie de remboursement de 30 jours",
|
||||
"orderSummary": "Résumé de la Commande",
|
||||
"qty": "Qté",
|
||||
"subtotal": "Sous-total",
|
||||
@@ -372,12 +373,49 @@
|
||||
"errorNoCheckout": "Pas de paiement actif. Veuillez réessayer.",
|
||||
"errorEmailRequired": "Veuillez entrer une adresse e-mail valide.",
|
||||
"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.",
|
||||
"errorCreatingOrder": "Échec de la création de la commande.",
|
||||
"orderConfirmed": "Commande Confirmée!",
|
||||
"thankYou": "Merci pour votre achat.",
|
||||
"orderNumber": "Numéro de Commande",
|
||||
"confirmationEmail": "Vous recevrez bientôt un email de confirmation. Nous vous contacterons pour organiser le paiement contre-remboursement.",
|
||||
"continueShoppingBtn": "Continuer les Achats"
|
||||
"continueShoppingBtn": "Continuer les Achats",
|
||||
"errorSelectPayment": "Veuillez sélectionner un mode de paiement."
|
||||
},
|
||||
"Payment": {
|
||||
"title": "Mode de Paiement",
|
||||
"selectMethod": "Sélectionner le mode de paiement",
|
||||
"securePayment": "Paiement sécurisé",
|
||||
"noMethodsAvailable": "Aucun mode de paiement disponible",
|
||||
"singleMethodNotice": "Le paiement à la livraison est le seul mode de paiement disponible pour votre région",
|
||||
"selected": "Sélectionné",
|
||||
"cod": {
|
||||
"name": "Paiement à la Livraison",
|
||||
"description": "Payez lors de la réception de votre commande",
|
||||
"instructions": {
|
||||
"title": "Instructions de Paiement",
|
||||
"prepareCash": "Préparer l'Argent",
|
||||
"prepareCashDesc": "Veuillez préparer le montant exact en espèces",
|
||||
"inspectOrder": "Inspecter Avant de Payer",
|
||||
"inspectOrderDesc": "Vous pouvez vérifier votre commande avant de payer",
|
||||
"noFee": "Pas de Frais Supplémentaires",
|
||||
"noFeeDesc": "Le paiement à la livraison est entièrement gratuit"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"name": "Carte de Crédit",
|
||||
"description": "Paiement en ligne sécurisé",
|
||||
"comingSoon": "Bientôt disponible"
|
||||
},
|
||||
"bank_transfer": {
|
||||
"name": "Virement Bancaire",
|
||||
"description": "Payez par virement bancaire",
|
||||
"comingSoon": "Bientôt disponible"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,6 +407,7 @@
|
||||
"cashOnDeliveryDesc": "Platite kada vam narudžbina bude isporučena na vrata.",
|
||||
"processing": "Obrađivanje...",
|
||||
"completeOrder": "Završi narudžbinu - {total}",
|
||||
"moneyBackGuarantee": "30-dnevna garancija povrata novca",
|
||||
"orderSummary": "Pregled narudžbine",
|
||||
"qty": "Kol",
|
||||
"subtotal": "Ukupno",
|
||||
@@ -418,12 +419,49 @@
|
||||
"errorNoCheckout": "Nema aktivne korpe. Molimo pokušajte ponovo.",
|
||||
"errorEmailRequired": "Molimo unesite validnu email adresu.",
|
||||
"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.",
|
||||
"errorCreatingOrder": "Neuspešno kreiranje narudžbine.",
|
||||
"orderConfirmed": "Narudžbina potvrđena!",
|
||||
"thankYou": "Hvala vam na kupovini!",
|
||||
"orderNumber": "Broj narudžbine",
|
||||
"confirmationEmail": "Uскoro ćete primiti email potvrde. Kontaktiraćemo vas da dogovorimo pouzećem plaćanje.",
|
||||
"continueShoppingBtn": "Nastavi kupovinu"
|
||||
"continueShoppingBtn": "Nastavi kupovinu",
|
||||
"errorSelectPayment": "Molimo izaberite način plaćanja."
|
||||
},
|
||||
"Payment": {
|
||||
"title": "Način Plaćanja",
|
||||
"selectMethod": "Izaberite način plaćanja",
|
||||
"securePayment": "Bezbedno plaćanje",
|
||||
"noMethodsAvailable": "Nema dostupnih načina plaćanja",
|
||||
"singleMethodNotice": "Plaćanje pouzećem je jedini dostupan način plaćanja za vašu lokaciju",
|
||||
"selected": "Izabrano",
|
||||
"cod": {
|
||||
"name": "Plaćanje Pouzećem",
|
||||
"description": "Platite kada primite porudžbinu",
|
||||
"instructions": {
|
||||
"title": "Uputstva za Plaćanje",
|
||||
"prepareCash": "Pripremite Gotovinu",
|
||||
"prepareCashDesc": "Molimo pripremite tačan iznos u gotovini",
|
||||
"inspectOrder": "Pregledajte Pre Plaćanja",
|
||||
"inspectOrderDesc": "Možete pregledati porudžbinu pre nego što platite",
|
||||
"noFee": "Bez Dodatne Naknade",
|
||||
"noFeeDesc": "Plaćanje pouzećem je potpuno besplatno"
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"name": "Kreditna Kartica",
|
||||
"description": "Bezbedno online plaćanje",
|
||||
"comingSoon": "Uskoro dostupno"
|
||||
},
|
||||
"bank_transfer": {
|
||||
"name": "Bankovni Transfer",
|
||||
"description": "Platite putem bankovnog transfera",
|
||||
"comingSoon": "Uskoro dostupno"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
106
src/lib/config/paymentMethods.ts
Normal file
106
src/lib/config/paymentMethods.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Payment methods configuration
|
||||
* Centralized configuration for all available payment methods
|
||||
*/
|
||||
|
||||
import type { PaymentMethod, Money } from '@/lib/saleor/payments/types';
|
||||
|
||||
/**
|
||||
* List of all available payment methods
|
||||
* Configure availability per channel, fees, and other settings
|
||||
*/
|
||||
export const paymentMethods: PaymentMethod[] = [
|
||||
{
|
||||
id: 'cod',
|
||||
name: 'Cash on Delivery',
|
||||
description: 'Pay when you receive your order',
|
||||
type: 'simple',
|
||||
fee: 0,
|
||||
available: true,
|
||||
availableInChannels: ['default-channel'], // Currently Serbia only
|
||||
icon: 'Banknote',
|
||||
},
|
||||
{
|
||||
id: 'card',
|
||||
name: 'Credit Card',
|
||||
description: 'Secure online payment',
|
||||
type: 'app',
|
||||
fee: 0,
|
||||
available: false, // Coming soon
|
||||
availableInChannels: ['default-channel'],
|
||||
icon: 'CreditCard',
|
||||
},
|
||||
{
|
||||
id: 'bank_transfer',
|
||||
name: 'Bank Transfer',
|
||||
description: 'Pay via bank transfer',
|
||||
type: 'simple',
|
||||
fee: 0,
|
||||
available: false, // Coming later
|
||||
availableInChannels: ['default-channel'],
|
||||
icon: 'Building2',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get payment methods available for a specific channel
|
||||
*/
|
||||
export function getPaymentMethodsForChannel(channel: string): PaymentMethod[] {
|
||||
return paymentMethods.filter(
|
||||
(method) =>
|
||||
method.available && method.availableInChannels.includes(channel)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific payment method by ID
|
||||
*/
|
||||
export function getPaymentMethodById(id: string): PaymentMethod | undefined {
|
||||
return paymentMethods.find((method) => method.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a payment method is available for a channel
|
||||
*/
|
||||
export function isPaymentMethodAvailable(
|
||||
methodId: string,
|
||||
channel: string
|
||||
): boolean {
|
||||
const method = getPaymentMethodById(methodId);
|
||||
if (!method) return false;
|
||||
return method.available && method.availableInChannels.includes(channel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default payment method ID
|
||||
* Used when no payment method is explicitly selected
|
||||
*/
|
||||
export const DEFAULT_PAYMENT_METHOD = 'cod';
|
||||
|
||||
/**
|
||||
* Channel configuration
|
||||
* Maps channels to their supported payment methods
|
||||
*/
|
||||
export const channelPaymentConfig: Record<string, string[]> = {
|
||||
'default-channel': ['cod'], // Serbia - COD only for now
|
||||
};
|
||||
|
||||
/**
|
||||
* Format payment method fee for display
|
||||
*/
|
||||
export function formatPaymentFee(fee: number, currency: string): string {
|
||||
if (fee === 0) return 'No additional fee';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(fee);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PSP reference for COD transactions
|
||||
* Format: COD-{orderNumber}-{timestamp}
|
||||
*/
|
||||
export function generateCODReference(orderNumber: string): string {
|
||||
const timestamp = Date.now();
|
||||
return `COD-${orderNumber}-${timestamp}`;
|
||||
}
|
||||
@@ -6,12 +6,13 @@ const httpLink = createHttpLink({
|
||||
});
|
||||
|
||||
const authLink = setContext((_, { headers }) => {
|
||||
// Saleor doesn't require auth for public queries
|
||||
// Add auth token here if needed for admin operations
|
||||
// Add auth token for admin operations
|
||||
const token = process.env.SALEOR_API_TOKEN;
|
||||
return {
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": "application/json",
|
||||
...(token && { "Authorization": `Bearer ${token}` }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -173,3 +173,54 @@ export const CHECKOUT_METADATA_UPDATE = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ORDER_METADATA_UPDATE = gql`
|
||||
mutation OrderMetadataUpdate($orderId: ID!, $metadata: [MetadataInput!]!) {
|
||||
updateMetadata(id: $orderId, input: $metadata) {
|
||||
item {
|
||||
... on Order {
|
||||
id
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CHECKOUT_LANGUAGE_CODE_UPDATE = gql`
|
||||
mutation CheckoutLanguageCodeUpdate($checkoutId: ID!, $languageCode: LanguageCodeEnum!) {
|
||||
checkoutLanguageCodeUpdate(checkoutId: $checkoutId, languageCode: $languageCode) {
|
||||
checkout {
|
||||
id
|
||||
languageCode
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const TRANSACTION_CREATE = gql`
|
||||
mutation CreateTransaction($orderId: ID!, $transaction: TransactionCreateInput!) {
|
||||
transactionCreate(id: $orderId, transaction: $transaction) {
|
||||
transaction {
|
||||
id
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
149
src/lib/saleor/payments/cod.ts
Normal file
149
src/lib/saleor/payments/cod.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Cash on Delivery (COD) payment logic
|
||||
* Handles creation of COD transactions in Saleor
|
||||
*/
|
||||
|
||||
import type { Money, TransactionInput } from '@/lib/saleor/payments/types';
|
||||
import { generateCODReference } from '@/lib/config/paymentMethods';
|
||||
|
||||
import { gql } from "@apollo/client";
|
||||
|
||||
/**
|
||||
* GraphQL mutation to create a transaction on an order
|
||||
*/
|
||||
export const CREATE_TRANSACTION_MUTATION = gql`
|
||||
mutation TransactionCreate($id: ID!, $transaction: TransactionCreateInput!) {
|
||||
transactionCreate(id: $id, transaction: $transaction) {
|
||||
transaction {
|
||||
id
|
||||
name
|
||||
pspReference
|
||||
status
|
||||
availableActions
|
||||
amountAuthorized {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
amountCharged {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Create a Cash on Delivery transaction configuration
|
||||
* @param orderNumber - The order number for reference
|
||||
* @param amount - The order total amount
|
||||
* @returns TransactionInput for Saleor
|
||||
*/
|
||||
export function createCODTransactionInput(
|
||||
orderNumber: string,
|
||||
amount: Money
|
||||
): TransactionInput {
|
||||
return {
|
||||
name: 'Cash on Delivery',
|
||||
pspReference: generateCODReference(orderNumber),
|
||||
availableActions: ['CHARGE'],
|
||||
amountAuthorized: {
|
||||
amount: 0,
|
||||
currency: amount.currency,
|
||||
},
|
||||
amountCharged: {
|
||||
amount: 0,
|
||||
currency: amount.currency,
|
||||
},
|
||||
externalUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create COD transaction on an order
|
||||
* This should be called after checkoutComplete creates the order
|
||||
*
|
||||
* @param orderId - Saleor order ID
|
||||
* @param orderNumber - Human-readable order number
|
||||
* @param amount - Order total amount
|
||||
* @returns Promise with transaction result
|
||||
*/
|
||||
export async function createCODTransaction(
|
||||
orderId: string,
|
||||
orderNumber: string,
|
||||
amount: Money
|
||||
): Promise<{ success: boolean; transaction?: unknown; errors?: unknown[] }> {
|
||||
try {
|
||||
// Note: This function should be called from a Server Component or API route
|
||||
// as it requires making a GraphQL mutation with authentication
|
||||
|
||||
const transactionInput = createCODTransactionInput(orderNumber, amount);
|
||||
|
||||
// The actual GraphQL call will be made in the checkout page
|
||||
// This function just prepares the input
|
||||
return {
|
||||
success: true,
|
||||
transaction: {
|
||||
orderId,
|
||||
...transactionInput,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error creating COD transaction:', error);
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ message: 'Failed to create COD transaction' }],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an order has a COD transaction
|
||||
* @param order - Order object from Saleor
|
||||
* @returns boolean
|
||||
*/
|
||||
export function hasCODTransaction(order: { transactions?: Array<{ name?: string }> }): boolean {
|
||||
if (!order.transactions || order.transactions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return order.transactions.some(
|
||||
(t) => t.name === 'Cash on Delivery'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get COD transaction from order
|
||||
* @param order - Order object from Saleor
|
||||
* @returns COD transaction or undefined
|
||||
*/
|
||||
export function getCODTransaction(order: { transactions?: Array<{ name?: string }> }) {
|
||||
if (!order.transactions) return undefined;
|
||||
|
||||
return order.transactions.find(
|
||||
(t) => t.name === 'Cash on Delivery'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format COD status for display
|
||||
* @param transactionStatus - Transaction status from Saleor
|
||||
* @returns Human-readable status
|
||||
*/
|
||||
export function formatCODStatus(transactionStatus: string): string {
|
||||
switch (transactionStatus) {
|
||||
case 'NOT_CHARGED':
|
||||
return 'Pending Collection';
|
||||
case 'CHARGED':
|
||||
return 'Paid';
|
||||
case 'CANCELLED':
|
||||
return 'Cancelled';
|
||||
default:
|
||||
return transactionStatus;
|
||||
}
|
||||
}
|
||||
62
src/lib/saleor/payments/types.ts
Normal file
62
src/lib/saleor/payments/types.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Payment method type definitions
|
||||
* Supports both simple payments (COD, Bank Transfer) and Payment Apps (Stripe, etc.)
|
||||
*/
|
||||
|
||||
export type PaymentType = 'simple' | 'async' | 'app';
|
||||
|
||||
export interface Money {
|
||||
amount: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface PaymentMethod {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: PaymentType;
|
||||
fee: number;
|
||||
available: boolean;
|
||||
availableInChannels: string[];
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface TransactionInput {
|
||||
name: string;
|
||||
pspReference: string;
|
||||
availableActions: string[];
|
||||
amountAuthorized?: Money;
|
||||
amountCharged?: Money;
|
||||
externalUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface AsyncSession {
|
||||
id: string;
|
||||
status: 'pending' | 'completed' | 'failed';
|
||||
paymentUrl?: string;
|
||||
qrCode?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface PaymentResult {
|
||||
type: 'order_created' | 'session_created' | 'error';
|
||||
order?: {
|
||||
id: string;
|
||||
number: string;
|
||||
};
|
||||
session?: AsyncSession;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PaymentStatus {
|
||||
status: 'pending' | 'completed' | 'failed';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CODTransactionConfig {
|
||||
name: string;
|
||||
pspReference: string;
|
||||
availableActions: ['CHARGE'];
|
||||
amountAuthorized: Money;
|
||||
amountCharged: Money;
|
||||
}
|
||||
@@ -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;
|
||||
482
src/lib/services/checkoutService.ts
Normal file
482
src/lib/services/checkoutService.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* Checkout Service - Domain layer for checkout operations
|
||||
*
|
||||
* This module encapsulates all checkout business logic, making it:
|
||||
* - Testable: Pure functions with no UI dependencies
|
||||
* - Composable: Steps can be chained, mocked, or replaced
|
||||
* - Type-safe: All inputs/outputs are strictly typed
|
||||
* - Resilient: Clear contracts prevent ordering mistakes
|
||||
*/
|
||||
|
||||
import { saleorClient } from "@/lib/saleor/client";
|
||||
import type { Checkout, CheckoutLine } from "@/types/saleor";
|
||||
import {
|
||||
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||
CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||
CHECKOUT_COMPLETE,
|
||||
CHECKOUT_EMAIL_UPDATE,
|
||||
CHECKOUT_METADATA_UPDATE,
|
||||
CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||
CHECKOUT_LANGUAGE_CODE_UPDATE,
|
||||
} from "@/lib/saleor/mutations/Checkout";
|
||||
|
||||
// ============================================================================
|
||||
// GraphQL Response Types
|
||||
// ============================================================================
|
||||
|
||||
interface GraphQLError {
|
||||
field?: string;
|
||||
message: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
interface CheckoutEmailUpdateResponse {
|
||||
checkoutEmailUpdate?: {
|
||||
checkout?: Checkout;
|
||||
errors?: GraphQLError[];
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckoutLanguageCodeUpdateResponse {
|
||||
checkoutLanguageCodeUpdate?: {
|
||||
checkout?: { id: string; languageCode: string };
|
||||
errors?: GraphQLError[];
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckoutShippingAddressUpdateResponse {
|
||||
checkoutShippingAddressUpdate?: {
|
||||
checkout?: Checkout;
|
||||
errors?: GraphQLError[];
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckoutBillingAddressUpdateResponse {
|
||||
checkoutBillingAddressUpdate?: {
|
||||
checkout?: Checkout;
|
||||
errors?: GraphQLError[];
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckoutShippingMethodUpdateResponse {
|
||||
checkoutShippingMethodUpdate?: {
|
||||
checkout?: Checkout;
|
||||
errors?: GraphQLError[];
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckoutMetadataUpdateResponse {
|
||||
updateMetadata?: {
|
||||
item?: {
|
||||
id: string;
|
||||
metadata?: Array<{ key: string; value: string }>;
|
||||
};
|
||||
errors?: GraphQLError[];
|
||||
};
|
||||
}
|
||||
|
||||
interface CheckoutCompleteResponse {
|
||||
checkoutComplete?: {
|
||||
order?: {
|
||||
id: string;
|
||||
number: string;
|
||||
status: string;
|
||||
created: string;
|
||||
total?: {
|
||||
gross: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
errors?: GraphQLError[];
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Domain Types
|
||||
// ============================================================================
|
||||
|
||||
export interface Address {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
streetAddress1: string;
|
||||
streetAddress2?: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface CheckoutInput {
|
||||
checkoutId: string;
|
||||
email: string;
|
||||
shippingAddress: Address;
|
||||
billingAddress: Address;
|
||||
shippingMethodId: string;
|
||||
languageCode: string;
|
||||
metadata: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface CheckoutResult {
|
||||
success: boolean;
|
||||
order?: {
|
||||
id: string;
|
||||
number: string;
|
||||
languageCode: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CheckoutStepResult<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Individual Checkout Steps (Composable Units)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Step 1: Update checkout email
|
||||
* Isolated, testable unit that does one thing
|
||||
*/
|
||||
export async function updateCheckoutEmail(
|
||||
checkoutId: string,
|
||||
email: string
|
||||
): Promise<CheckoutStepResult> {
|
||||
const { data } = await saleorClient.mutate<CheckoutEmailUpdateResponse>({
|
||||
mutation: CHECKOUT_EMAIL_UPDATE,
|
||||
variables: { checkoutId, email },
|
||||
});
|
||||
|
||||
if (data?.checkoutEmailUpdate?.errors?.length) {
|
||||
const error = data.checkoutEmailUpdate.errors[0];
|
||||
if (error.message.includes("Couldn't resolve to a node")) {
|
||||
return { success: false, error: "CHECKOUT_EXPIRED" };
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: Update language code
|
||||
* CRITICAL: Must be called before checkoutComplete for correct email language
|
||||
*/
|
||||
export async function updateCheckoutLanguage(
|
||||
checkoutId: string,
|
||||
languageCode: string
|
||||
): Promise<CheckoutStepResult> {
|
||||
const { data } = await saleorClient.mutate<CheckoutLanguageCodeUpdateResponse>({
|
||||
mutation: CHECKOUT_LANGUAGE_CODE_UPDATE,
|
||||
variables: { checkoutId, languageCode: languageCode.toUpperCase() },
|
||||
});
|
||||
|
||||
if (data?.checkoutLanguageCodeUpdate?.errors?.length) {
|
||||
return { success: false, error: data.checkoutLanguageCodeUpdate.errors[0].message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3: Update shipping address
|
||||
*/
|
||||
export async function updateShippingAddress(
|
||||
checkoutId: string,
|
||||
address: Address
|
||||
): Promise<CheckoutStepResult> {
|
||||
const { data } = await saleorClient.mutate<CheckoutShippingAddressUpdateResponse>({
|
||||
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||
variables: {
|
||||
checkoutId,
|
||||
shippingAddress: {
|
||||
firstName: address.firstName,
|
||||
lastName: address.lastName,
|
||||
streetAddress1: address.streetAddress1,
|
||||
streetAddress2: address.streetAddress2 || "",
|
||||
city: address.city,
|
||||
postalCode: address.postalCode,
|
||||
country: address.country,
|
||||
phone: address.phone,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (data?.checkoutShippingAddressUpdate?.errors?.length) {
|
||||
return { success: false, error: data.checkoutShippingAddressUpdate.errors[0].message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 4: Update billing address
|
||||
*/
|
||||
export async function updateBillingAddress(
|
||||
checkoutId: string,
|
||||
address: Address
|
||||
): Promise<CheckoutStepResult> {
|
||||
const { data } = await saleorClient.mutate<CheckoutBillingAddressUpdateResponse>({
|
||||
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||
variables: {
|
||||
checkoutId,
|
||||
billingAddress: {
|
||||
firstName: address.firstName,
|
||||
lastName: address.lastName,
|
||||
streetAddress1: address.streetAddress1,
|
||||
streetAddress2: address.streetAddress2 || "",
|
||||
city: address.city,
|
||||
postalCode: address.postalCode,
|
||||
country: address.country,
|
||||
phone: address.phone,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (data?.checkoutBillingAddressUpdate?.errors?.length) {
|
||||
return { success: false, error: data.checkoutBillingAddressUpdate.errors[0].message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 5: Update shipping method
|
||||
*/
|
||||
export async function updateShippingMethod(
|
||||
checkoutId: string,
|
||||
shippingMethodId: string
|
||||
): Promise<CheckoutStepResult> {
|
||||
const { data } = await saleorClient.mutate<CheckoutShippingMethodUpdateResponse>({
|
||||
mutation: CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||
variables: { checkoutId, shippingMethodId },
|
||||
});
|
||||
|
||||
if (data?.checkoutShippingMethodUpdate?.errors?.length) {
|
||||
return { success: false, error: data.checkoutShippingMethodUpdate.errors[0].message };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 6: Update metadata
|
||||
* Non-critical - failures are logged but don't stop checkout
|
||||
*/
|
||||
export async function updateCheckoutMetadata(
|
||||
checkoutId: string,
|
||||
metadata: Record<string, string>
|
||||
): Promise<CheckoutStepResult> {
|
||||
const metadataArray = Object.entries(metadata).map(([key, value]) => ({ key, value }));
|
||||
|
||||
const { data } = await saleorClient.mutate<CheckoutMetadataUpdateResponse>({
|
||||
mutation: CHECKOUT_METADATA_UPDATE,
|
||||
variables: { checkoutId, metadata: metadataArray },
|
||||
});
|
||||
|
||||
if (data?.updateMetadata?.errors?.length) {
|
||||
// Metadata is non-critical, log but don't fail
|
||||
console.warn("Failed to save checkout metadata:", data.updateMetadata.errors);
|
||||
return { success: true }; // Still return success
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Final Step: Complete checkout
|
||||
* Returns the created order
|
||||
*/
|
||||
export async function completeCheckout(
|
||||
checkoutId: string
|
||||
): Promise<CheckoutStepResult<{ id: string; number: string; languageCode: string }>> {
|
||||
const { data } = await saleorClient.mutate<CheckoutCompleteResponse>({
|
||||
mutation: CHECKOUT_COMPLETE,
|
||||
variables: { checkoutId },
|
||||
});
|
||||
|
||||
if (data?.checkoutComplete?.errors?.length) {
|
||||
return { success: false, error: data.checkoutComplete.errors[0].message };
|
||||
}
|
||||
|
||||
const order = data?.checkoutComplete?.order;
|
||||
if (!order) {
|
||||
return { success: false, error: "Order creation failed - no order returned" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: order.id,
|
||||
number: order.number,
|
||||
languageCode: "EN", // Default fallback since checkoutComplete doesn't return languageCode directly
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Checkout Pipeline (Composed Steps)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Execute full checkout pipeline with proper ordering
|
||||
*
|
||||
* This function enforces the correct sequence of operations:
|
||||
* 1. Email (identifies customer)
|
||||
* 2. Language (MUST be before complete for email language!)
|
||||
* 3. Addresses
|
||||
* 4. Shipping method
|
||||
* 5. Metadata
|
||||
* 6. Complete
|
||||
*
|
||||
* If any step fails, the pipeline stops and returns the error.
|
||||
* This prevents partial checkouts and ensures data consistency.
|
||||
*/
|
||||
export async function executeCheckoutPipeline(
|
||||
input: CheckoutInput
|
||||
): Promise<CheckoutResult> {
|
||||
const { checkoutId, email, shippingAddress, billingAddress, shippingMethodId, languageCode, metadata } = input;
|
||||
|
||||
// Step 1: Email
|
||||
const emailResult = await updateCheckoutEmail(checkoutId, email);
|
||||
if (!emailResult.success) {
|
||||
return { success: false, error: emailResult.error };
|
||||
}
|
||||
|
||||
// Step 2: Language (CRITICAL for email language)
|
||||
const languageResult = await updateCheckoutLanguage(checkoutId, languageCode);
|
||||
if (!languageResult.success) {
|
||||
return { success: false, error: languageResult.error };
|
||||
}
|
||||
|
||||
// Step 3: Shipping Address
|
||||
const shippingResult = await updateShippingAddress(checkoutId, shippingAddress);
|
||||
if (!shippingResult.success) {
|
||||
return { success: false, error: shippingResult.error };
|
||||
}
|
||||
|
||||
// Step 4: Billing Address
|
||||
const billingResult = await updateBillingAddress(checkoutId, billingAddress);
|
||||
if (!billingResult.success) {
|
||||
return { success: false, error: billingResult.error };
|
||||
}
|
||||
|
||||
// Step 5: Shipping Method
|
||||
const methodResult = await updateShippingMethod(checkoutId, shippingMethodId);
|
||||
if (!methodResult.success) {
|
||||
return { success: false, error: methodResult.error };
|
||||
}
|
||||
|
||||
// Step 6: Metadata (non-critical, continues on failure)
|
||||
await updateCheckoutMetadata(checkoutId, metadata);
|
||||
|
||||
// Step 7: Complete checkout
|
||||
const completeResult = await completeCheckout(checkoutId);
|
||||
if (!completeResult.success || !completeResult.data) {
|
||||
return { success: false, error: completeResult.error };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
order: completeResult.data,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation Helpers
|
||||
// ============================================================================
|
||||
|
||||
export function validateAddress(address: Partial<Address>): string | null {
|
||||
if (!address.firstName?.trim()) return "First name is required";
|
||||
if (!address.lastName?.trim()) return "Last name is required";
|
||||
if (!address.streetAddress1?.trim()) return "Street address is required";
|
||||
if (!address.city?.trim()) return "City is required";
|
||||
if (!address.postalCode?.trim()) return "Postal code is required";
|
||||
if (!address.country?.trim()) return "Country is required";
|
||||
if (!address.phone?.trim() || address.phone.length < 8) return "Valid phone number is required";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateEmail(email: string): string | null {
|
||||
if (!email?.trim()) return "Email is required";
|
||||
if (!email.includes("@")) return "Invalid email format";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateCheckoutInput(input: CheckoutInput): string | null {
|
||||
const emailError = validateEmail(input.email);
|
||||
if (emailError) return emailError;
|
||||
|
||||
const shippingError = validateAddress(input.shippingAddress);
|
||||
if (shippingError) return `Shipping ${shippingError}`;
|
||||
|
||||
const billingError = validateAddress(input.billingAddress);
|
||||
if (billingError) return `Billing ${billingError}`;
|
||||
|
||||
if (!input.shippingMethodId) return "Shipping method is required";
|
||||
if (!input.checkoutId) return "Checkout ID is required";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Checkout Service Class (High-level API)
|
||||
// ============================================================================
|
||||
|
||||
export class CheckoutService {
|
||||
constructor(private checkoutId: string) {}
|
||||
|
||||
async updateEmail(email: string): Promise<CheckoutStepResult> {
|
||||
return updateCheckoutEmail(this.checkoutId, email);
|
||||
}
|
||||
|
||||
async updateLanguage(languageCode: string): Promise<CheckoutStepResult> {
|
||||
return updateCheckoutLanguage(this.checkoutId, languageCode);
|
||||
}
|
||||
|
||||
async updateShippingAddress(address: Address): Promise<CheckoutStepResult> {
|
||||
return updateShippingAddress(this.checkoutId, address);
|
||||
}
|
||||
|
||||
async updateBillingAddress(address: Address): Promise<CheckoutStepResult> {
|
||||
return updateBillingAddress(this.checkoutId, address);
|
||||
}
|
||||
|
||||
async updateShippingMethod(shippingMethodId: string): Promise<CheckoutStepResult> {
|
||||
return updateShippingMethod(this.checkoutId, shippingMethodId);
|
||||
}
|
||||
|
||||
async updateMetadata(metadata: Record<string, string>): Promise<CheckoutStepResult> {
|
||||
return updateCheckoutMetadata(this.checkoutId, metadata);
|
||||
}
|
||||
|
||||
async complete(): Promise<CheckoutStepResult<{ id: string; number: string; languageCode: string }>> {
|
||||
return completeCheckout(this.checkoutId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute full checkout with validation
|
||||
*/
|
||||
async execute(input: Omit<CheckoutInput, "checkoutId">): Promise<CheckoutResult> {
|
||||
const fullInput: CheckoutInput = {
|
||||
...input,
|
||||
checkoutId: this.checkoutId,
|
||||
};
|
||||
|
||||
const validationError = validateCheckoutInput(fullInput);
|
||||
if (validationError) {
|
||||
return { success: false, error: validationError };
|
||||
}
|
||||
|
||||
return executeCheckoutPipeline(fullInput);
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function for creating checkout service
|
||||
export function createCheckoutService(checkoutId: string): CheckoutService {
|
||||
return new CheckoutService(checkoutId);
|
||||
}
|
||||
@@ -58,22 +58,25 @@ interface GetCheckoutResponse {
|
||||
interface SaleorCheckoutStore {
|
||||
checkout: Checkout | null;
|
||||
checkoutToken: string | null;
|
||||
languageCode: string | null;
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
|
||||
// Actions
|
||||
initCheckout: () => Promise<void>;
|
||||
addLine: (variantId: string, quantity: number) => Promise<void>;
|
||||
updateLine: (lineId: string, quantity: number) => Promise<void>;
|
||||
removeLine: (lineId: string) => Promise<void>;
|
||||
setEmail: (email: string) => Promise<void>;
|
||||
setLanguageCode: (languageCode: string) => void;
|
||||
refreshCheckout: () => Promise<void>;
|
||||
toggleCart: () => void;
|
||||
openCart: () => void;
|
||||
closeCart: () => void;
|
||||
clearError: () => void;
|
||||
|
||||
clearCheckout: () => void;
|
||||
|
||||
// Getters
|
||||
getLineCount: () => number;
|
||||
getTotal: () => number;
|
||||
@@ -85,13 +88,14 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
||||
(set, get) => ({
|
||||
checkout: null,
|
||||
checkoutToken: null,
|
||||
languageCode: null,
|
||||
isOpen: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
initCheckout: async () => {
|
||||
const { checkoutToken } = get();
|
||||
|
||||
const { checkoutToken, languageCode } = get();
|
||||
|
||||
if (checkoutToken) {
|
||||
// Try to fetch existing checkout
|
||||
try {
|
||||
@@ -99,7 +103,7 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
||||
query: GET_CHECKOUT,
|
||||
variables: { token: checkoutToken },
|
||||
});
|
||||
|
||||
|
||||
if (data?.checkout) {
|
||||
set({ checkout: data.checkout });
|
||||
return;
|
||||
@@ -108,8 +112,8 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
||||
// Checkout not found or expired, create new one
|
||||
}
|
||||
}
|
||||
|
||||
// Create new checkout
|
||||
|
||||
// Create new checkout with language code
|
||||
try {
|
||||
const { data } = await saleorClient.mutate<CheckoutCreateResponse>({
|
||||
mutation: CHECKOUT_CREATE,
|
||||
@@ -117,10 +121,11 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
||||
input: {
|
||||
channel: CHANNEL,
|
||||
lines: [],
|
||||
languageCode: languageCode ? languageCode.toUpperCase() : undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
if (data?.checkoutCreate?.checkout) {
|
||||
set({
|
||||
checkout: data.checkoutCreate.checkout,
|
||||
@@ -294,6 +299,8 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
||||
openCart: () => set({ isOpen: true }),
|
||||
closeCart: () => set({ isOpen: false }),
|
||||
clearError: () => set({ error: null }),
|
||||
setLanguageCode: (languageCode: string) => set({ languageCode }),
|
||||
clearCheckout: () => set({ checkout: null, checkoutToken: null }),
|
||||
|
||||
getLineCount: () => {
|
||||
const { checkout } = get();
|
||||
|
||||
Reference in New Issue
Block a user