Merge dev: COD payment method implementation
Some checks failed
Build and Deploy / build (push) Has been cancelled
Some checks failed
Build and Deploy / build (push) Has been cancelled
Features: - Add Cash on Delivery (COD) payment method - Modular payment configuration system - PaymentMethodSelector and PaymentMethodCard components - 30-day money back guarantee badge - Checkout language fix for multilingual emails - Cart reset after order completion - Service layer architecture Note: Orders are UNCONFIRMED until manually confirmed in dashboard. Auto-confirmation has permission issues in Saleor.
This commit is contained in:
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
|
||||||
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,13 @@ import { saleorClient } from "@/lib/saleor/client";
|
|||||||
import { useAnalytics } from "@/lib/analytics";
|
import { useAnalytics } from "@/lib/analytics";
|
||||||
import {
|
import {
|
||||||
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||||
CHECKOUT_BILLING_ADDRESS_UPDATE,
|
ORDER_CONFIRM,
|
||||||
CHECKOUT_COMPLETE,
|
|
||||||
CHECKOUT_EMAIL_UPDATE,
|
|
||||||
CHECKOUT_METADATA_UPDATE,
|
|
||||||
CHECKOUT_SHIPPING_METHOD_UPDATE,
|
|
||||||
} from "@/lib/saleor/mutations/Checkout";
|
} 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 { GET_CHECKOUT_BY_ID } from "@/lib/saleor/queries/Checkout";
|
||||||
import type { Checkout } from "@/types/saleor";
|
import type { Checkout } from "@/types/saleor";
|
||||||
|
import { createCheckoutService, type Address } from "@/lib/services/checkoutService";
|
||||||
|
|
||||||
interface ShippingAddressUpdateResponse {
|
interface ShippingAddressUpdateResponse {
|
||||||
checkoutShippingAddressUpdate?: {
|
checkoutShippingAddressUpdate?: {
|
||||||
@@ -29,44 +28,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 {
|
interface CheckoutQueryResponse {
|
||||||
checkout?: Checkout;
|
checkout?: Checkout;
|
||||||
}
|
}
|
||||||
@@ -96,7 +57,7 @@ export default function CheckoutPage() {
|
|||||||
const t = useTranslations("Checkout");
|
const t = useTranslations("Checkout");
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore();
|
const { checkout, refreshCheckout, clearCheckout, getLines, getTotal } = useSaleorCheckoutStore();
|
||||||
const { trackCheckoutStarted, trackCheckoutStep, trackOrderCompleted, identifyUser } = useAnalytics();
|
const { trackCheckoutStarted, trackCheckoutStep, trackOrderCompleted, identifyUser } = useAnalytics();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -104,6 +65,7 @@ export default function CheckoutPage() {
|
|||||||
const [orderNumber, setOrderNumber] = useState<string | null>(null);
|
const [orderNumber, setOrderNumber] = useState<string | null>(null);
|
||||||
|
|
||||||
const [sameAsShipping, setSameAsShipping] = useState(true);
|
const [sameAsShipping, setSameAsShipping] = useState(true);
|
||||||
|
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>(DEFAULT_PAYMENT_METHOD);
|
||||||
const [shippingAddress, setShippingAddress] = useState<AddressForm>({
|
const [shippingAddress, setShippingAddress] = useState<AddressForm>({
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
@@ -277,131 +239,115 @@ export default function CheckoutPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!selectedPaymentMethod) {
|
||||||
|
setError(t("errorSelectPayment"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("Completing order...");
|
console.log("Completing order via CheckoutService...");
|
||||||
|
|
||||||
console.log("Step 1: Updating email...");
|
// Create checkout service instance
|
||||||
const emailResult = await saleorClient.mutate<EmailUpdateResponse>({
|
const checkoutService = createCheckoutService(checkout.id);
|
||||||
mutation: CHECKOUT_EMAIL_UPDATE,
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute checkout pipeline
|
||||||
|
const result = await checkoutService.execute({
|
||||||
|
email: shippingAddress.email,
|
||||||
|
shippingAddress: serviceShippingAddress,
|
||||||
|
billingAddress: serviceBillingAddress,
|
||||||
|
shippingMethodId: selectedShippingMethod,
|
||||||
|
languageCode: locale.toUpperCase(),
|
||||||
|
metadata: {
|
||||||
|
phone: shippingAddress.phone,
|
||||||
|
shippingPhone: shippingAddress.phone,
|
||||||
|
userLanguage: locale,
|
||||||
|
userLocale: locale,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
throw new Error(result.error || t("errorCreatingOrder"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success!
|
||||||
|
setOrderNumber(result.order.number);
|
||||||
|
setOrderComplete(true);
|
||||||
|
|
||||||
|
// Auto-confirm the order
|
||||||
|
try {
|
||||||
|
console.log("Auto-confirming order:", result.order.id);
|
||||||
|
await saleorClient.mutate({
|
||||||
|
mutation: ORDER_CONFIRM,
|
||||||
variables: {
|
variables: {
|
||||||
checkoutId: checkout.id,
|
orderId: result.order.id,
|
||||||
email: shippingAddress.email,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
console.log("Order confirmed successfully");
|
||||||
|
} catch (confirmError) {
|
||||||
|
console.error("Failed to auto-confirm order:", confirmError);
|
||||||
|
// Don't fail the checkout if confirmation fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
|
||||||
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
|
console.log("Order completed successfully:", result.order.number);
|
||||||
const errorMessage = emailResult.data.checkoutEmailUpdate.errors[0].message;
|
|
||||||
if (errorMessage.includes("Couldn't resolve to a node")) {
|
|
||||||
console.error("Checkout not found, clearing cart...");
|
|
||||||
localStorage.removeItem('cart');
|
|
||||||
localStorage.removeItem('checkoutId');
|
|
||||||
window.location.href = `/${locale}/products`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw new Error(`Email update failed: ${errorMessage}`);
|
|
||||||
}
|
|
||||||
console.log("Step 1: Email updated successfully");
|
|
||||||
|
|
||||||
console.log("Step 2: Updating billing address...");
|
|
||||||
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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 2: Billing address updated successfully");
|
|
||||||
|
|
||||||
console.log("Step 3: Setting shipping method...");
|
|
||||||
const shippingMethodResult = await saleorClient.mutate<ShippingMethodUpdateResponse>({
|
|
||||||
mutation: CHECKOUT_SHIPPING_METHOD_UPDATE,
|
|
||||||
variables: {
|
|
||||||
checkoutId: checkout.id,
|
|
||||||
shippingMethodId: selectedShippingMethod,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shippingMethodResult.data?.checkoutShippingMethodUpdate?.errors && shippingMethodResult.data.checkoutShippingMethodUpdate.errors.length > 0) {
|
|
||||||
throw new Error(`Shipping method update failed: ${shippingMethodResult.data.checkoutShippingMethodUpdate.errors[0].message}`);
|
|
||||||
}
|
|
||||||
console.log("Step 3: Shipping method set successfully");
|
|
||||||
|
|
||||||
console.log("Step 4: Saving metadata...");
|
|
||||||
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 },
|
|
||||||
{ key: "userLanguage", value: locale },
|
|
||||||
{ key: "userLocale", value: locale },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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 4: Phone number saved successfully");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Step 5: 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"));
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error("Checkout error:", err);
|
console.error("Checkout error:", err);
|
||||||
|
|
||||||
@@ -660,6 +606,23 @@ export default function CheckoutPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || lines.length === 0 || !selectedShippingMethod}
|
disabled={isLoading || lines.length === 0 || !selectedShippingMethod}
|
||||||
|
|||||||
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";
|
||||||
@@ -361,6 +361,7 @@
|
|||||||
"cashOnDeliveryDesc": "Bezahlen Sie, wenn Ihre Bestellung an Ihre Tür geliefert wird.",
|
"cashOnDeliveryDesc": "Bezahlen Sie, wenn Ihre Bestellung an Ihre Tür geliefert wird.",
|
||||||
"processing": "Wird bearbeitet...",
|
"processing": "Wird bearbeitet...",
|
||||||
"completeOrder": "Bestellung abschließen - {total}",
|
"completeOrder": "Bestellung abschließen - {total}",
|
||||||
|
"moneyBackGuarantee": "30 Tage Geld-zurück-Garantie",
|
||||||
"orderSummary": "Bestellübersicht",
|
"orderSummary": "Bestellübersicht",
|
||||||
"qty": "Menge",
|
"qty": "Menge",
|
||||||
"subtotal": "Zwischensumme",
|
"subtotal": "Zwischensumme",
|
||||||
@@ -383,6 +384,38 @@
|
|||||||
"thankYou": "Vielen Dank für Ihren Einkauf.",
|
"thankYou": "Vielen Dank für Ihren Einkauf.",
|
||||||
"orderNumber": "Bestellnummer",
|
"orderNumber": "Bestellnummer",
|
||||||
"confirmationEmail": "Sie erhalten in Kürze eine Bestätigungs-E-Mail. Wir werden Sie kontaktieren, um Nachnahme zu arrangieren.",
|
"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.",
|
"cashOnDeliveryDesc": "Pay when your order is delivered to your door.",
|
||||||
"processing": "Processing...",
|
"processing": "Processing...",
|
||||||
"completeOrder": "Complete Order - {total}",
|
"completeOrder": "Complete Order - {total}",
|
||||||
|
"moneyBackGuarantee": "30-Day Money-Back Guarantee",
|
||||||
"orderSummary": "Order Summary",
|
"orderSummary": "Order Summary",
|
||||||
"qty": "Qty",
|
"qty": "Qty",
|
||||||
"subtotal": "Subtotal",
|
"subtotal": "Subtotal",
|
||||||
@@ -430,6 +431,38 @@
|
|||||||
"thankYou": "Thank you for your purchase.",
|
"thankYou": "Thank you for your purchase.",
|
||||||
"orderNumber": "Order Number",
|
"orderNumber": "Order Number",
|
||||||
"confirmationEmail": "You will receive a confirmation email shortly. We will contact you to arrange Cash on Delivery.",
|
"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.",
|
"cashOnDeliveryDesc": "Payez lorsque votre commande est livrée à votre porte.",
|
||||||
"processing": "En cours...",
|
"processing": "En cours...",
|
||||||
"completeOrder": "Finaliser la Commande - {total}",
|
"completeOrder": "Finaliser la Commande - {total}",
|
||||||
|
"moneyBackGuarantee": "Garantie de remboursement de 30 jours",
|
||||||
"orderSummary": "Résumé de la Commande",
|
"orderSummary": "Résumé de la Commande",
|
||||||
"qty": "Qté",
|
"qty": "Qté",
|
||||||
"subtotal": "Sous-total",
|
"subtotal": "Sous-total",
|
||||||
@@ -383,6 +384,38 @@
|
|||||||
"thankYou": "Merci pour votre achat.",
|
"thankYou": "Merci pour votre achat.",
|
||||||
"orderNumber": "Numéro de Commande",
|
"orderNumber": "Numéro de Commande",
|
||||||
"confirmationEmail": "Vous recevrez bientôt un email de confirmation. Nous vous contacterons pour organiser le paiement contre-remboursement.",
|
"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.",
|
"cashOnDeliveryDesc": "Platite kada vam narudžbina bude isporučena na vrata.",
|
||||||
"processing": "Obrađivanje...",
|
"processing": "Obrađivanje...",
|
||||||
"completeOrder": "Završi narudžbinu - {total}",
|
"completeOrder": "Završi narudžbinu - {total}",
|
||||||
|
"moneyBackGuarantee": "30-dnevna garancija povrata novca",
|
||||||
"orderSummary": "Pregled narudžbine",
|
"orderSummary": "Pregled narudžbine",
|
||||||
"qty": "Kol",
|
"qty": "Kol",
|
||||||
"subtotal": "Ukupno",
|
"subtotal": "Ukupno",
|
||||||
@@ -429,6 +430,38 @@
|
|||||||
"thankYou": "Hvala vam na kupovini!",
|
"thankYou": "Hvala vam na kupovini!",
|
||||||
"orderNumber": "Broj narudžbine",
|
"orderNumber": "Broj narudžbine",
|
||||||
"confirmationEmail": "Uскoro ćete primiti email potvrde. Kontaktiraćemo vas da dogovorimo pouzećem plaćanje.",
|
"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 }) => {
|
const authLink = setContext((_, { headers }) => {
|
||||||
// Saleor doesn't require auth for public queries
|
// Add auth token for admin operations
|
||||||
// Add auth token here if needed for admin operations
|
const token = process.env.SALEOR_API_TOKEN;
|
||||||
return {
|
return {
|
||||||
headers: {
|
headers: {
|
||||||
...headers,
|
...headers,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
...(token && { "Authorization": `Bearer ${token}` }),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -173,3 +173,70 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ORDER_CONFIRM = gql`
|
||||||
|
mutation OrderConfirm($orderId: ID!) {
|
||||||
|
orderConfirm(id: $orderId) {
|
||||||
|
order {
|
||||||
|
id
|
||||||
|
number
|
||||||
|
status
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
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);
|
||||||
|
}
|
||||||
@@ -75,6 +75,7 @@ interface SaleorCheckoutStore {
|
|||||||
openCart: () => void;
|
openCart: () => void;
|
||||||
closeCart: () => void;
|
closeCart: () => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
clearCheckout: () => void;
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
getLineCount: () => number;
|
getLineCount: () => number;
|
||||||
@@ -299,6 +300,7 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
|||||||
closeCart: () => set({ isOpen: false }),
|
closeCart: () => set({ isOpen: false }),
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
setLanguageCode: (languageCode: string) => set({ languageCode }),
|
setLanguageCode: (languageCode: string) => set({ languageCode }),
|
||||||
|
clearCheckout: () => set({ checkout: null, checkoutToken: null }),
|
||||||
|
|
||||||
getLineCount: () => {
|
getLineCount: () => {
|
||||||
const { checkout } = get();
|
const { checkout } = get();
|
||||||
|
|||||||
Reference in New Issue
Block a user