Compare commits
56 Commits
feature/i1
...
feature/on
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f9081cb52 | ||
|
|
7f35dc57c6 | ||
|
|
7d63f4fbcd | ||
|
|
b78b081d29 | ||
|
|
676dda4642 | ||
|
|
c8d184f9dc | ||
|
|
322c4c805b | ||
|
|
bcf74e1fd1 | ||
|
|
7ca756fc5a | ||
|
|
ca363a2406 | ||
|
|
5ec0e6c92c | ||
|
|
ee574cb736 | ||
|
|
a419337d99 | ||
|
|
09294fd752 | ||
|
|
a6ebcf408c | ||
|
|
f66f9b87ab | ||
|
|
85e41bfcc4 | ||
|
|
84b85f5291 | ||
|
|
c98677405a | ||
|
|
4a63098e3e | ||
|
|
2e6668ff0d | ||
|
|
eb9a798d40 | ||
|
|
ab7dfbe48b | ||
|
|
319f62b923 | ||
|
|
f73f3b8576 | ||
|
|
4d428b3ff0 | ||
|
|
646d057970 | ||
|
|
a0fa0f5401 | ||
|
|
aa7a0ed3c8 | ||
|
|
15a65758d7 | ||
|
|
9c2e4e1383 | ||
|
|
d0e3ee3201 | ||
|
|
b5f8ddbaaa | ||
|
|
6dbaf99b29 | ||
|
|
cdd3f9c77e | ||
|
|
17367024c2 | ||
|
|
bf628f873f | ||
|
|
eb311568db | ||
|
|
c9aaacc452 | ||
|
|
e08e919e83 | ||
|
|
923f805d47 | ||
|
|
6e0a05c314 | ||
|
|
5576946829 | ||
|
|
ef83538d0b | ||
|
|
4fcd4b3ba8 | ||
|
|
b8b3a57e6f | ||
|
|
00f63c32f8 | ||
|
|
3d8a77dafa | ||
|
|
bfce7dcca0 | ||
|
|
8f780c3585 | ||
|
|
9a61564e3c | ||
|
|
28a6e58dba | ||
|
|
569a3e65fe | ||
|
|
1ba81a1fde | ||
|
|
df95e729fc | ||
|
|
513dcb7fea |
367
ONE-PAGE-CHECKOUT-PLAN.md
Normal file
367
ONE-PAGE-CHECKOUT-PLAN.md
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
# One-Page Checkout Implementation Plan
|
||||||
|
|
||||||
|
**Branch:** `feature/one-page-checkout`
|
||||||
|
**Status:** In Development
|
||||||
|
**Priority:** High
|
||||||
|
**Phone Requirement:** Required (not optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Convert the current two-phase checkout into a streamlined one-page checkout experience where customers can see all fields at once and complete their order in a single action.
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
- **Phase 1:** Collect email, shipping address → fetch shipping methods
|
||||||
|
- **Phase 2:** Select shipping method, billing address → complete order
|
||||||
|
- **Total API calls:** 6-7 sequential requests across 2 user interactions
|
||||||
|
|
||||||
|
### Target State
|
||||||
|
- **Single Page:** All fields visible simultaneously
|
||||||
|
- **Dynamic updates:** Shipping methods fetch automatically when address changes
|
||||||
|
- **Single submit:** One "Complete Order" button
|
||||||
|
- **Optimized API:** 3-4 sequential steps (parallel where possible)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Must-Have
|
||||||
|
- [ ] All checkout fields visible on single page
|
||||||
|
- [ ] Phone number is **required** (strict validation)
|
||||||
|
- [ ] Shipping methods fetch automatically (debounced) when address changes
|
||||||
|
- [ ] Real-time total calculation (updates when shipping method selected)
|
||||||
|
- [ ] Single "Complete Order" submit button
|
||||||
|
- [ ] Section-based validation with inline errors
|
||||||
|
- [ ] Auto-scroll to first error on validation failure
|
||||||
|
- [ ] Preserve form data on error
|
||||||
|
|
||||||
|
### UX Requirements
|
||||||
|
- [ ] Clear visual hierarchy (Contact → Shipping → Billing → Shipping Method → Payment)
|
||||||
|
- [ ] Collapsible sections (optional - all expanded by default)
|
||||||
|
- [ ] Loading states for shipping method fetching
|
||||||
|
- [ ] Disabled submit button until all required fields valid
|
||||||
|
- [ ] Success confirmation page (existing)
|
||||||
|
|
||||||
|
### Technical Requirements
|
||||||
|
- [ ] Debounced shipping method API calls (500ms)
|
||||||
|
- [ ] Optimistic UI updates where possible
|
||||||
|
- [ ] Proper error handling per section
|
||||||
|
- [ ] Analytics events for checkout steps
|
||||||
|
- [ ] Mobile-responsive layout
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Layout
|
||||||
|
|
||||||
|
### Left Column (Form - 60% width on desktop)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ 1. Contact Information │
|
||||||
|
│ ├─ Email * [________________] │
|
||||||
|
│ └─ Phone * [________________] │
|
||||||
|
│ [+381... format hint] │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ 2. Shipping Address │
|
||||||
|
│ ├─ First Name * [____________] │
|
||||||
|
│ ├─ Last Name * [_____________] │
|
||||||
|
│ ├─ Country * [▼ Serbia ▼] │
|
||||||
|
│ ├─ Street Address * [________] │
|
||||||
|
│ ├─ Apt/Suite [______________] │
|
||||||
|
│ ├─ City * [_________________] │
|
||||||
|
│ └─ Postal Code * [__________] │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ 3. Billing Address │
|
||||||
|
│ [✓] Same as shipping address │
|
||||||
|
│ (Fields hidden when checked) │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ 4. Shipping Method │
|
||||||
|
│ (Loading... / Select to see │
|
||||||
|
│ available options) │
|
||||||
|
│ ○ Standard (2-3 days) 400 RSD │
|
||||||
|
│ ○ Express (1-2 days) 800 RSD │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ 5. Payment Method │
|
||||||
|
│ ● Cash on Delivery │
|
||||||
|
│ (Additional payment methods TBD) │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ [ Complete Order - 3,600 RSD ] │
|
||||||
|
│ Loading spinner when processing │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Right Column (Order Summary - 40% width on desktop)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Order Summary │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Product Image Serum x1 3,200 │
|
||||||
|
│ RSD │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ Subtotal 3,200 RSD │
|
||||||
|
│ Shipping 400 RSD │
|
||||||
|
│ ───────────────────────────────── │
|
||||||
|
│ Total 3,600 RSD │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mobile Layout
|
||||||
|
Single column, stacked sections with sticky order summary at bottom.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Implementation
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Form state (existing)
|
||||||
|
const [shippingAddress, setShippingAddress] = useState<AddressForm>({...});
|
||||||
|
const [billingAddress, setBillingAddress] = useState<AddressForm>({...});
|
||||||
|
const [sameAsShipping, setSameAsShipping] = useState(true);
|
||||||
|
|
||||||
|
// New state
|
||||||
|
const [paymentMethod, setPaymentMethod] = useState<string>("cod");
|
||||||
|
const [errors, setErrors] = useState<ValidationErrors>({
|
||||||
|
contact: null,
|
||||||
|
shipping: null,
|
||||||
|
billing: null,
|
||||||
|
shippingMethod: null,
|
||||||
|
general: null,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Debounced Shipping Method Fetching
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAddressComplete(shippingAddress)) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fetchShippingMethods();
|
||||||
|
}, 500); // 500ms debounce
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [shippingAddress]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const validationRules = {
|
||||||
|
email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
|
||||||
|
phone: (value) => {
|
||||||
|
// Country-specific validation
|
||||||
|
// Serbia: +381 XX XXX XXXX
|
||||||
|
// Bosnia: +387 XX XXX XXX
|
||||||
|
// etc.
|
||||||
|
},
|
||||||
|
required: (value) => value.trim().length > 0,
|
||||||
|
postalCode: (value, country) => {
|
||||||
|
// Country-specific postal code validation
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Call Sequence
|
||||||
|
|
||||||
|
**Optimized Flow (parallel + sequential):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: Validation (client-side)
|
||||||
|
├─ Validate all fields
|
||||||
|
└─ Show inline errors
|
||||||
|
|
||||||
|
Step 2: Parallel Independent Calls
|
||||||
|
├─ Update Email
|
||||||
|
└─ Update Shipping Address
|
||||||
|
(Both can run simultaneously)
|
||||||
|
|
||||||
|
Step 3: Conditional Call
|
||||||
|
└─ Update Billing Address (if different from shipping)
|
||||||
|
|
||||||
|
Step 4: Sequential Dependent Calls
|
||||||
|
├─ Update Shipping Method
|
||||||
|
├─ Update Metadata (phone, language, payment method)
|
||||||
|
└─ Complete Checkout
|
||||||
|
|
||||||
|
Total: 4 sequential steps vs current 7+
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling Strategy
|
||||||
|
|
||||||
|
**Field-level:**
|
||||||
|
- Real-time validation on blur
|
||||||
|
- Visual indicators (red border, error message)
|
||||||
|
- Prevent submit if validation fails
|
||||||
|
|
||||||
|
**Section-level:**
|
||||||
|
- Group errors by section
|
||||||
|
- Show section header in red if has errors
|
||||||
|
- Expand section if collapsed and has errors
|
||||||
|
|
||||||
|
**Form-level:**
|
||||||
|
- On submit: validate all fields
|
||||||
|
- If errors: scroll to first error, show summary
|
||||||
|
- If API error: show in relevant section, preserve data
|
||||||
|
|
||||||
|
**API-level:**
|
||||||
|
- Map Saleor errors to form fields when possible
|
||||||
|
- Generic error: show at top of form
|
||||||
|
- Network error: show retry button
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
### Primary Files
|
||||||
|
|
||||||
|
1. **`/src/app/[locale]/checkout/page.tsx`**
|
||||||
|
- Major refactor of checkout flow
|
||||||
|
- Combine Phase 1 & Phase 2 into single component
|
||||||
|
- Add debounced shipping method fetching
|
||||||
|
- Implement section-based validation
|
||||||
|
- Optimize API call sequence
|
||||||
|
|
||||||
|
2. **`/src/lib/saleor/mutations/Checkout.ts`**
|
||||||
|
- Ensure all mutations available
|
||||||
|
- Add metadata update mutation if needed
|
||||||
|
|
||||||
|
3. **`/src/lib/saleor/queries/Checkout.ts`**
|
||||||
|
- Ensure checkout query returns shipping methods
|
||||||
|
|
||||||
|
### Translation Files
|
||||||
|
|
||||||
|
4. **`/messages/sr.json`** (and other language files)
|
||||||
|
- Add new translation keys for one-page checkout
|
||||||
|
- Section headers
|
||||||
|
- Validation messages
|
||||||
|
- Button labels
|
||||||
|
|
||||||
|
### Styling
|
||||||
|
|
||||||
|
5. **`/src/app/globals.css`** (or Tailwind config)
|
||||||
|
- Ensure consistent form styling
|
||||||
|
- Add validation state styles
|
||||||
|
- Loading spinner styles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Core Structure (Day 1-2)
|
||||||
|
- [ ] Refactor checkout page layout
|
||||||
|
- [ ] Display all sections simultaneously
|
||||||
|
- [ ] Keep existing form logic working
|
||||||
|
- [ ] Test existing flow still works
|
||||||
|
|
||||||
|
### Phase 2: Dynamic Shipping Methods (Day 3)
|
||||||
|
- [ ] Implement debounced fetching
|
||||||
|
- [ ] Add loading states
|
||||||
|
- [ ] Display shipping methods inline
|
||||||
|
- [ ] Update total when method selected
|
||||||
|
|
||||||
|
### Phase 3: Validation & Error Handling (Day 4)
|
||||||
|
- [ ] Implement field-level validation
|
||||||
|
- [ ] Add section-based error display
|
||||||
|
- [ ] Auto-scroll to errors
|
||||||
|
- [ ] Test all validation scenarios
|
||||||
|
|
||||||
|
### Phase 4: Optimization (Day 5)
|
||||||
|
- [ ] Optimize API call sequence
|
||||||
|
- [ ] Add parallel mutation execution
|
||||||
|
- [ ] Improve loading states
|
||||||
|
- [ ] Add optimistic updates
|
||||||
|
|
||||||
|
### Phase 5: Polish (Day 6)
|
||||||
|
- [ ] Mobile responsiveness
|
||||||
|
- [ ] Analytics events
|
||||||
|
- [ ] Accessibility improvements
|
||||||
|
- [ ] Final testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
### Functionality Tests
|
||||||
|
- [ ] Fill all fields, submit successfully
|
||||||
|
- [ ] Verify order created in Saleor
|
||||||
|
- [ ] Verify emails sent
|
||||||
|
- [ ] Change shipping method, verify total updates
|
||||||
|
- [ ] Change address, verify shipping methods refetch
|
||||||
|
|
||||||
|
### Validation Tests
|
||||||
|
- [ ] Submit with empty email → email error
|
||||||
|
- [ ] Submit with empty phone → phone error
|
||||||
|
- [ ] Submit with invalid email format → format error
|
||||||
|
- [ ] Submit with invalid phone → format error
|
||||||
|
- [ ] Submit with empty required fields → field errors
|
||||||
|
- [ ] Submit without selecting shipping method → shipping error
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- [ ] Slow network (test debouncing)
|
||||||
|
- [ ] No shipping methods available
|
||||||
|
- [ ] API failure during submission
|
||||||
|
- [ ] Partial API failure (some mutations succeed)
|
||||||
|
- [ ] Browser refresh (preserve data?)
|
||||||
|
|
||||||
|
### Mobile Tests
|
||||||
|
- [ ] Layout works on iPhone SE
|
||||||
|
- [ ] Layout works on iPhone 14 Pro Max
|
||||||
|
- [ ] Touch targets large enough
|
||||||
|
- [ ] Scroll behavior smooth
|
||||||
|
|
||||||
|
### Accessibility Tests
|
||||||
|
- [ ] Tab navigation works
|
||||||
|
- [ ] Screen reader friendly
|
||||||
|
- [ ] Error announcements
|
||||||
|
- [ ] Focus management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rollout Strategy
|
||||||
|
|
||||||
|
1. **Development:** Complete on feature branch
|
||||||
|
2. **Testing:** Local testing with all scenarios
|
||||||
|
3. **Staging:** Deploy to dev.manoonoils.com
|
||||||
|
4. **Monitoring:** Check for errors, conversion rates
|
||||||
|
5. **Production:** Merge to master and deploy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
- **Conversion Rate:** Should increase (fewer steps = less drop-off)
|
||||||
|
- **Time to Complete:** Should decrease (single page vs two phases)
|
||||||
|
- **Error Rate:** Should decrease (better validation)
|
||||||
|
- **Mobile Completion:** Should improve (optimized for mobile)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements (Out of Scope)
|
||||||
|
|
||||||
|
- [ ] Save addresses for logged-in users
|
||||||
|
- [ ] Address autocomplete (Google Maps)
|
||||||
|
- [ ] Multiple payment methods (Stripe, etc.)
|
||||||
|
- [ ] Guest checkout improvements
|
||||||
|
- [ ] Order notes/comments field
|
||||||
|
- [ ] Gift wrapping options
|
||||||
|
- [ ] Promo code input
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Phone number is **strictly required** - validate format per country
|
||||||
|
- Keep existing checkout success page
|
||||||
|
- Maintain multi-language support
|
||||||
|
- Ensure analytics tracking works
|
||||||
|
- Don't break existing cart functionality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Created:** March 28, 2026
|
||||||
|
**Branch:** feature/one-page-checkout
|
||||||
|
**Next Step:** Start Phase 1 - Core Structure
|
||||||
666
docs/roadmap/FEATURE_ROADMAP.md
Normal file
666
docs/roadmap/FEATURE_ROADMAP.md
Normal file
@@ -0,0 +1,666 @@
|
|||||||
|
# Storefront Feature Roadmap
|
||||||
|
|
||||||
|
> Strategic roadmap for increasing profitability, conversion rates, and SEO traffic.
|
||||||
|
|
||||||
|
## Quick Stats
|
||||||
|
- **Total Features:** 20
|
||||||
|
- **Estimated Timeline:** 12-16 weeks
|
||||||
|
- **Priority Categories:** Foundation → Quick Wins → Revenue → Growth
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Foundation (Weeks 1-3)
|
||||||
|
*These features must be completed first as they enable other features*
|
||||||
|
|
||||||
|
### 1. Enhanced Product Reviews System
|
||||||
|
**Impact:** High | **Effort:** Medium | **Revenue Impact:** +15-30% conversion
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- Allow customers to submit reviews with photos
|
||||||
|
- Star ratings display on product cards
|
||||||
|
- "Verified Purchase" badges
|
||||||
|
- Review moderation dashboard
|
||||||
|
- Review request email automation
|
||||||
|
|
||||||
|
**Why First:**
|
||||||
|
- Required for Rich Snippets (SEO feature #9)
|
||||||
|
- Social proof enables all conversion optimizations
|
||||||
|
- Reviews feed into email sequences
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- Database schema for reviews
|
||||||
|
- Image upload/storage (S3/MinIO)
|
||||||
|
- Moderation workflow
|
||||||
|
- Saleor integration or standalone system
|
||||||
|
|
||||||
|
**Dependencies:** None (foundation feature)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Structured Data / Rich Snippets (JSON-LD)
|
||||||
|
**Impact:** High | **Effort:** Low | **Revenue Impact:** +10-20% CTR
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- Product Schema (price, availability, ratings)
|
||||||
|
- Review Schema (star ratings in Google)
|
||||||
|
- Organization Schema (brand info)
|
||||||
|
- BreadcrumbList Schema (navigation in SERPs)
|
||||||
|
- FAQ Schema for product pages
|
||||||
|
|
||||||
|
**Why First:**
|
||||||
|
- Needs reviews system (#1) for review schema
|
||||||
|
- Immediate SEO benefit
|
||||||
|
- No dependencies after reviews
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- next/head component for JSON-LD injection
|
||||||
|
- Dynamic schema generation per page
|
||||||
|
- Testing with Google's Rich Results Test
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- ✅ Product Reviews System (#1) - for review ratings
|
||||||
|
- ⏳ Product catalog (already exists)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Open Graph & Twitter Card Meta Tags
|
||||||
|
**Impact:** Medium | **Effort:** Low | **Revenue Impact:** Social sharing boost
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- og:title, og:description, og:image for all pages
|
||||||
|
- Twitter Card meta tags
|
||||||
|
- Dynamic meta tags for product pages
|
||||||
|
- Social share preview optimization
|
||||||
|
|
||||||
|
**Why First:**
|
||||||
|
- Quick win, low effort
|
||||||
|
- Improves social media traffic quality
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- Extend existing metadata.ts
|
||||||
|
- Generate dynamic OG images (optional)
|
||||||
|
|
||||||
|
**Dependencies:** None (parallel with #2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Quick Wins (Weeks 4-5)
|
||||||
|
*High impact, low effort features that show immediate results*
|
||||||
|
|
||||||
|
### 4. Free Shipping Progress Bar
|
||||||
|
**Impact:** High | **Effort:** Low | **Revenue Impact:** +15-25% AOV
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- Visual progress bar in cart drawer
|
||||||
|
- "Add X RSD more for free shipping" messaging
|
||||||
|
- Animated progress indicator
|
||||||
|
- Threshold: 5,000 RSD (already configured)
|
||||||
|
|
||||||
|
**Why Now:**
|
||||||
|
- Increases average order value immediately
|
||||||
|
- Simple cart component modification
|
||||||
|
- No backend dependencies
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- Cart drawer component update
|
||||||
|
- Real-time calculation based on cart total
|
||||||
|
- Confetti animation when threshold reached (optional)
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Sticky "Add to Cart" Button (Mobile)
|
||||||
|
**Impact:** High | **Effort:** Low | **Revenue Impact:** +10-20% mobile conversion
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- Fixed position button on mobile product pages
|
||||||
|
- Price and "Add to Cart" always visible while scrolling
|
||||||
|
- Smooth scroll to variant selector if needed
|
||||||
|
|
||||||
|
**Why Now:**
|
||||||
|
- Mobile is likely 60%+ of traffic
|
||||||
|
- Single component change
|
||||||
|
- High conversion impact
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- CSS position: sticky/fixed
|
||||||
|
- Mobile breakpoint detection
|
||||||
|
- Smooth scroll behavior
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Trust Signals Enhancement
|
||||||
|
**Impact:** Medium | **Effort:** Low | **Revenue Impact:** +5-10% conversion
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- Payment method icons (Visa, Mastercard, PayPal) in footer/checkout
|
||||||
|
- "Secure SSL Checkout" badge
|
||||||
|
- 30-day money-back guarantee badge
|
||||||
|
- "Made in Serbia" / local production badge
|
||||||
|
|
||||||
|
**Why Now:**
|
||||||
|
- Reduces checkout anxiety
|
||||||
|
- Visual asset creation only
|
||||||
|
- No code complexity
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- SVG icons for payment methods
|
||||||
|
- Badge component updates
|
||||||
|
- Footer component modification
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Revenue Optimization (Weeks 6-10)
|
||||||
|
*Features that directly increase revenue and LTV*
|
||||||
|
|
||||||
|
### 7. Abandoned Cart Recovery System
|
||||||
|
**Impact:** Critical | **Effort:** Medium | **Revenue Impact:** 10-15% cart recovery
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- 3-email sequence: 1 hour, 24 hours, 72 hours
|
||||||
|
- Email 3 includes 10% discount code
|
||||||
|
- Exit intent detection
|
||||||
|
- SMS fallback (optional)
|
||||||
|
- Recovery tracking dashboard
|
||||||
|
|
||||||
|
**Why Now:**
|
||||||
|
- Highest ROI feature
|
||||||
|
- Requires email infrastructure
|
||||||
|
- Builds on existing order system
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- Cart abandonment detection
|
||||||
|
- Email template system (extend existing)
|
||||||
|
- Discount code generation
|
||||||
|
- Cron job or queue system
|
||||||
|
- Tracking pixel for recovery attribution
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- ✅ Email service (Resend already configured)
|
||||||
|
- ✅ Order notification service (already exists)
|
||||||
|
- ⏳ Discount code system (if not in Saleor)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. One-Click Upsells at Checkout
|
||||||
|
**Impact:** High | **Effort:** Medium | **Revenue Impact:** +20-30% AOV
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- "Complete your routine" modal after add-to-cart
|
||||||
|
- Smart product recommendations based on cart contents
|
||||||
|
- One-click add (no page reload)
|
||||||
|
- Bundle discounts (buy 2 get 10% off)
|
||||||
|
|
||||||
|
**Why Now:**
|
||||||
|
- Increases AOV significantly
|
||||||
|
- Leverages existing cart system
|
||||||
|
- Works well with skincare routines
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- Upsell algorithm (category-based)
|
||||||
|
- Modal component
|
||||||
|
- Cart API updates
|
||||||
|
- Bundle pricing logic
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- ✅ Cart system (already exists)
|
||||||
|
- ⏳ Product relationships data (manual or AI-based)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Exit-Intent Lead Capture Popup
|
||||||
|
**Impact:** Medium | **Effort:** Low | **Revenue Impact:** +5-15% email list growth
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- Detects when user moves mouse to close tab/address bar
|
||||||
|
- Shows email signup with 10% discount offer
|
||||||
|
- Mobile: scroll-up detection or time-based
|
||||||
|
- Dismissible with "No thanks" option
|
||||||
|
|
||||||
|
**Why Now:**
|
||||||
|
- Captures leaving traffic
|
||||||
|
- Builds email list for newsletters
|
||||||
|
- Simple implementation
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- Exit intent detection library (ouibounce or custom)
|
||||||
|
- Email capture form
|
||||||
|
- Discount code integration
|
||||||
|
- Cookie/session management (show once per user)
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- ⏳ Email list management (CRM or Mailchimp)
|
||||||
|
- ⏳ Discount code system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. Subscription / Recurring Orders
|
||||||
|
**Impact:** High | **Effort:** High | **Revenue Impact:** Predictable recurring revenue
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- "Subscribe & Save 15%" option on product pages
|
||||||
|
- Monthly/quarterly delivery intervals
|
||||||
|
- Automatic billing (Stripe subscriptions)
|
||||||
|
- Skip/pause/cancel management portal
|
||||||
|
- Replenishment reminders
|
||||||
|
|
||||||
|
**Why Now:**
|
||||||
|
- Skincare has high reorder rates
|
||||||
|
- Predictable revenue stream
|
||||||
|
- Increases LTV significantly
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- Stripe Subscription integration
|
||||||
|
- Customer portal for management
|
||||||
|
- Inventory forecasting
|
||||||
|
- Email notifications for upcoming orders
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- ✅ Stripe integration (check existing)
|
||||||
|
- ⏳ Customer account system (if not exists)
|
||||||
|
- ⏳ Inventory management enhancements
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Engagement & Support (Weeks 11-12)
|
||||||
|
*Features that improve customer experience and reduce friction*
|
||||||
|
|
||||||
|
### 11. Live Chat Widget (WhatsApp Business)
|
||||||
|
**Impact:** Medium | **Effort:** Low | **Revenue Impact:** +10-15% conversion
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- WhatsApp Business integration (most popular in Serbia)
|
||||||
|
- Floating chat button
|
||||||
|
- Auto-reply for common questions
|
||||||
|
- Business hours indicator
|
||||||
|
- Chat history
|
||||||
|
|
||||||
|
**Why Now:**
|
||||||
|
- Real-time customer support
|
||||||
|
- High trust factor for skincare advice
|
||||||
|
- Low implementation cost
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- WhatsApp Business API or click-to-chat
|
||||||
|
- Floating button component
|
||||||
|
- Auto-response templates
|
||||||
|
- Mobile-optimized
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. Product Comparison Tool
|
||||||
|
**Impact:** Medium | **Effort:** Medium | **Revenue Impact:** +5-10% conversion
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- Compare 2-3 products side-by-side
|
||||||
|
- Compare ingredients, benefits, price, reviews
|
||||||
|
- Save comparison for later
|
||||||
|
- "Help me choose" quiz (optional)
|
||||||
|
|
||||||
|
**Why Now:**
|
||||||
|
- Reduces decision paralysis
|
||||||
|
- Increases time on site
|
||||||
|
- Helps customers find right product
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- Comparison table component
|
||||||
|
- Product selection interface
|
||||||
|
- Data normalization across products
|
||||||
|
- Persistent state (URL params or session)
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- ✅ Product data (already in Saleor)
|
||||||
|
- ⏳ Enhanced product attributes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. Enhanced Urgency Elements
|
||||||
|
**Impact:** Medium | **Effort:** Low | **Revenue Impact:** +5-15% conversion
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- Real stock counter ("Only 3 left in stock")
|
||||||
|
- Countdown timer for limited promotions
|
||||||
|
- Recent purchase notifications ("Sarah from Belgrade just bought...")
|
||||||
|
- Low stock email alerts
|
||||||
|
|
||||||
|
**Why Now:**
|
||||||
|
- Scarcity drives action
|
||||||
|
- Builds on existing urgency text
|
||||||
|
- Simple implementation
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- Real-time stock display
|
||||||
|
- Countdown timer component
|
||||||
|
- Fake social proof (recent purchase ticker)
|
||||||
|
- Sale scheduling system
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- ✅ Inventory data from Saleor
|
||||||
|
- ⏳ Sale/promotion management system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Content & SEO Growth (Weeks 13-16)
|
||||||
|
*Long-term traffic growth through content and SEO*
|
||||||
|
|
||||||
|
### 14. Blog / Content Marketing Hub
|
||||||
|
**Impact:** High | **Effort:** High | **Revenue Impact:** Organic traffic growth
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- Blog section with categories
|
||||||
|
- Skincare guides and tutorials
|
||||||
|
- Ingredient education
|
||||||
|
- Before/after case studies
|
||||||
|
- Video content integration
|
||||||
|
- SEO-optimized articles
|
||||||
|
|
||||||
|
**Why Now:**
|
||||||
|
- Long-term organic traffic
|
||||||
|
- Positions brand as authority
|
||||||
|
- Content for social media
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- Blog CMS (Headless CMS or markdown)
|
||||||
|
- Category/tags system
|
||||||
|
- Author profiles
|
||||||
|
- Related articles
|
||||||
|
- Comment system (optional)
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- ⏳ Headless CMS (Strapi, Sanity, or Contentful)
|
||||||
|
- ⏳ Content strategy and writing resources
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 15. Enhanced Product Pages (Video & Guides)
|
||||||
|
**Impact:** Medium | **Effort:** Medium | **Revenue Impact:** +10-20% conversion
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- Product application tutorial videos
|
||||||
|
- Ingredient glossary popup
|
||||||
|
- "How to use" photo guides
|
||||||
|
- Skin type recommendations
|
||||||
|
- Routine builder tool
|
||||||
|
|
||||||
|
**Why Now:**
|
||||||
|
- Increases product understanding
|
||||||
|
- Reduces returns
|
||||||
|
- Video content for social
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- Video hosting (Vimeo/YouTube)
|
||||||
|
- Accordion components for guides
|
||||||
|
- Skin type quiz logic
|
||||||
|
- Rich media product gallery
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- ⏳ Video production
|
||||||
|
- ⏳ Content creation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 16. FAQ Section with Schema Markup
|
||||||
|
**Impact:** Medium | **Effort:** Low | **Revenue Impact:** SEO + reduced support
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- Comprehensive FAQ page
|
||||||
|
- Product-specific FAQs
|
||||||
|
- Searchable FAQ
|
||||||
|
- FAQ schema markup for Google
|
||||||
|
- Categorized questions
|
||||||
|
|
||||||
|
**Why Now:**
|
||||||
|
- Reduces customer service load
|
||||||
|
- SEO benefit with FAQ schema
|
||||||
|
- Easy content creation
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- FAQ accordion component
|
||||||
|
- Search functionality
|
||||||
|
- JSON-LD FAQ schema
|
||||||
|
- Category filtering
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Email Marketing Automation (Weeks 14-16)
|
||||||
|
*Leveraging email for retention and LTV*
|
||||||
|
|
||||||
|
### 17. Post-Purchase Email Sequence
|
||||||
|
**Impact:** High | **Effort:** Medium | **Revenue Impact:** +20-30% retention
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- Order confirmation (already exists ✓)
|
||||||
|
- Shipping notification (already exists ✓)
|
||||||
|
- Delivery confirmation
|
||||||
|
- "How's your product?" (7 days later)
|
||||||
|
- Review request (14 days later)
|
||||||
|
- Replenishment reminder (30/60 days)
|
||||||
|
- Win-back campaign (90 days no purchase)
|
||||||
|
|
||||||
|
**Why Now:**
|
||||||
|
- Maximizes LTV
|
||||||
|
- Uses existing email infrastructure
|
||||||
|
- Automated revenue
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- Email sequence automation
|
||||||
|
- Timing logic based on delivery
|
||||||
|
- Dynamic content based on purchase
|
||||||
|
- Unsubscribe management
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- ✅ Email service (Resend)
|
||||||
|
- ✅ Order tracking (already exists)
|
||||||
|
- ⏳ Delivery tracking integration (optional)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 18. Segment-Based Email Campaigns
|
||||||
|
**Impact:** Medium | **Effort:** Medium | **Revenue Impact:** +15-25% email revenue
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- VIP customers segment (high LTV)
|
||||||
|
- Inactive customers (win-back offers)
|
||||||
|
- Product-specific education sequences
|
||||||
|
- Seasonal campaigns (winter skincare, summer protection)
|
||||||
|
- Birthday discounts
|
||||||
|
|
||||||
|
**Why Now:**
|
||||||
|
- Personalized marketing
|
||||||
|
- Higher engagement than broadcasts
|
||||||
|
- Uses customer data
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- Customer segmentation logic
|
||||||
|
- Email template variants
|
||||||
|
- Automation workflows
|
||||||
|
- A/B testing capability
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- ✅ Email service
|
||||||
|
- ⏳ CRM or customer data platform
|
||||||
|
- ⏳ Email marketing platform (Mailchimp, Klaviyo, or custom)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7: Advanced Features (Future)
|
||||||
|
*Nice-to-have features for later phases*
|
||||||
|
|
||||||
|
### 19. Wishlist / Save for Later
|
||||||
|
**Impact:** Medium | **Effort:** Medium | **Revenue Impact:** +5-10% conversion
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- Heart icon on product cards
|
||||||
|
- Save items without account (cookies) or with account
|
||||||
|
- Email reminders for saved items
|
||||||
|
- Share wishlist feature
|
||||||
|
- Back-in-stock notifications
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- Wishlist database/storage
|
||||||
|
- Heart icon toggle
|
||||||
|
- Wishlist page
|
||||||
|
- Email triggers
|
||||||
|
- Social sharing
|
||||||
|
|
||||||
|
**Dependencies:**
|
||||||
|
- ⏳ Customer account system (optional)
|
||||||
|
- ⏳ Back-in-stock notification system
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 20. Google Analytics 4 + Enhanced E-commerce
|
||||||
|
**Impact:** Medium | **Effort:** Low | **Revenue Impact:** Better attribution
|
||||||
|
|
||||||
|
**Description:**
|
||||||
|
- GA4 implementation alongside OpenPanel
|
||||||
|
- Enhanced e-commerce events
|
||||||
|
- Funnel visualization
|
||||||
|
- Attribution modeling
|
||||||
|
- A/B testing framework (Google Optimize)
|
||||||
|
|
||||||
|
**Why Later:**
|
||||||
|
- OpenPanel already provides analytics
|
||||||
|
- GA4 is supplementary
|
||||||
|
- Data analysis takes time
|
||||||
|
|
||||||
|
**Technical Requirements:**
|
||||||
|
- GA4 script injection
|
||||||
|
- Event mapping to GA4 standards
|
||||||
|
- E-commerce data layer
|
||||||
|
- Conversion tracking setup
|
||||||
|
|
||||||
|
**Dependencies:** None (can be done anytime)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependency Graph
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1: Foundation
|
||||||
|
├── 1. Product Reviews (START HERE)
|
||||||
|
├── 2. Structured Data ← depends on #1
|
||||||
|
└── 3. Open Graph Tags (parallel)
|
||||||
|
|
||||||
|
Phase 2: Quick Wins
|
||||||
|
├── 4. Free Shipping Bar (independent)
|
||||||
|
├── 5. Sticky Add to Cart (independent)
|
||||||
|
└── 6. Trust Signals (independent)
|
||||||
|
|
||||||
|
Phase 3: Revenue
|
||||||
|
├── 7. Abandoned Cart ← needs email system ✓
|
||||||
|
├── 8. One-Click Upsells ← needs cart ✓
|
||||||
|
├── 9. Exit Intent ← needs email CRM
|
||||||
|
└── 10. Subscriptions ← needs Stripe
|
||||||
|
|
||||||
|
Phase 4: Engagement
|
||||||
|
├── 11. Live Chat (independent)
|
||||||
|
├── 12. Product Comparison ← needs product data ✓
|
||||||
|
└── 13. Urgency Elements ← needs inventory ✓
|
||||||
|
|
||||||
|
Phase 5: Content
|
||||||
|
├── 14. Blog ← needs CMS
|
||||||
|
├── 15. Enhanced PDPs ← needs video content
|
||||||
|
└── 16. FAQ (independent)
|
||||||
|
|
||||||
|
Phase 6: Email
|
||||||
|
├── 17. Post-Purchase ← needs #7 foundation
|
||||||
|
└── 18. Segmentation ← needs CRM
|
||||||
|
|
||||||
|
Phase 7: Future
|
||||||
|
├── 19. Wishlist (nice to have)
|
||||||
|
└── 20. GA4 (supplementary)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Priority Matrix
|
||||||
|
|
||||||
|
| Feature | Revenue Impact | SEO Impact | Effort | Priority |
|
||||||
|
|---------|---------------|------------|--------|----------|
|
||||||
|
| 1. Product Reviews | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Medium | **P0** |
|
||||||
|
| 2. Structured Data | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | Low | **P0** |
|
||||||
|
| 7. Abandoned Cart | ⭐⭐⭐⭐⭐ | ⭐ | Medium | **P0** |
|
||||||
|
| 4. Free Shipping Bar | ⭐⭐⭐⭐ | ⭐ | Low | **P1** |
|
||||||
|
| 8. One-Click Upsells | ⭐⭐⭐⭐⭐ | ⭐ | Medium | **P1** |
|
||||||
|
| 5. Sticky Add to Cart | ⭐⭐⭐⭐ | ⭐ | Low | **P1** |
|
||||||
|
| 10. Subscriptions | ⭐⭐⭐⭐⭐ | ⭐ | High | **P1** |
|
||||||
|
| 17. Post-Purchase Email | ⭐⭐⭐⭐ | ⭐ | Medium | **P1** |
|
||||||
|
| 14. Blog | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | High | **P2** |
|
||||||
|
| 9. Exit Intent | ⭐⭐⭐ | ⭐ | Low | **P2** |
|
||||||
|
| 11. Live Chat | ⭐⭐⭐ | ⭐ | Low | **P2** |
|
||||||
|
| 15. Enhanced PDPs | ⭐⭐⭐⭐ | ⭐⭐⭐ | Medium | **P2** |
|
||||||
|
|
||||||
|
**Legend:**
|
||||||
|
- **P0:** Start immediately, highest ROI
|
||||||
|
- **P1:** Core revenue features
|
||||||
|
- **P2:** Growth and optimization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resource Requirements
|
||||||
|
|
||||||
|
### Development Team
|
||||||
|
- **Frontend:** 1-2 developers (Next.js/React)
|
||||||
|
- **Backend:** 1 developer (Node.js/GraphQL)
|
||||||
|
- **DevOps:** Part-time (CI/CD, infrastructure)
|
||||||
|
|
||||||
|
### External Resources
|
||||||
|
- **Content Writer:** For blog, FAQs, product descriptions
|
||||||
|
- **Video Production:** For tutorials and guides
|
||||||
|
- **Email Copywriter:** For email sequences
|
||||||
|
- **Designer:** For banners, badges, marketing assets
|
||||||
|
|
||||||
|
### Third-Party Services
|
||||||
|
- **Email Marketing:** Resend (✓), Klaviyo (optional upgrade)
|
||||||
|
- **Reviews Platform:** Loox, Judge.me, or custom
|
||||||
|
- **Live Chat:** WhatsApp Business (free), Intercom (paid)
|
||||||
|
- **Analytics:** OpenPanel (✓), Google Analytics 4
|
||||||
|
- **CMS:** Strapi (self-hosted) or Sanity
|
||||||
|
- **CDN:** Cloudflare (✓)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
### Revenue KPIs
|
||||||
|
- **Conversion Rate:** Current → Target (+20%)
|
||||||
|
- **Average Order Value:** Current → Target (+25%)
|
||||||
|
- **Customer Lifetime Value:** Current → Target (+40%)
|
||||||
|
- **Cart Abandonment Rate:** Current → Target (-30%)
|
||||||
|
|
||||||
|
### SEO KPIs
|
||||||
|
- **Organic Traffic:** +50% in 6 months
|
||||||
|
- **Click-Through Rate:** +15% with rich snippets
|
||||||
|
- **Keyword Rankings:** Top 3 for 20 target keywords
|
||||||
|
- **Domain Authority:** Increase by 10 points
|
||||||
|
|
||||||
|
### Engagement KPIs
|
||||||
|
- **Email List Growth:** +500 subscribers/month
|
||||||
|
- **Review Submission Rate:** 10% of orders
|
||||||
|
- **Repeat Purchase Rate:** 30% within 90 days
|
||||||
|
- **Customer Support Tickets:** -20% with FAQ
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Test everything:** A/B test major changes
|
||||||
|
- **Mobile-first:** 60%+ traffic is mobile
|
||||||
|
- **Performance:** Keep Core Web Vitals green
|
||||||
|
- **Accessibility:** WCAG 2.1 AA compliance
|
||||||
|
- **Privacy:** GDPR compliance for EU customers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Last Updated: March 2026*
|
||||||
|
*Next Review: Quarterly*
|
||||||
@@ -76,6 +76,14 @@ spec:
|
|||||||
value: "https://api.manoonoils.com/graphql/"
|
value: "https://api.manoonoils.com/graphql/"
|
||||||
- name: NEXT_PUBLIC_SITE_URL
|
- name: NEXT_PUBLIC_SITE_URL
|
||||||
value: "https://dev.manoonoils.com"
|
value: "https://dev.manoonoils.com"
|
||||||
|
- name: DASHBOARD_URL
|
||||||
|
value: "https://dashboard.manoonoils.com"
|
||||||
|
- name: NEXT_PUBLIC_OPENPANEL_CLIENT_ID
|
||||||
|
value: "fa61f8ae-0b5d-4187-a9b1-5a04b0025674"
|
||||||
|
- name: OPENPANEL_CLIENT_SECRET
|
||||||
|
value: "91126be0d1e78e657e0427df82733832.c6d30edf6ee673da9650a883604169a13ab8579a0dde70cb39b477f4cf441f90"
|
||||||
|
- name: OPENPANEL_API_URL
|
||||||
|
value: "https://op.nodecrew.me/api"
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: workspace
|
- name: workspace
|
||||||
mountPath: /workspace
|
mountPath: /workspace
|
||||||
@@ -108,6 +116,16 @@ spec:
|
|||||||
value: "https://api.manoonoils.com/graphql/"
|
value: "https://api.manoonoils.com/graphql/"
|
||||||
- name: NEXT_PUBLIC_SITE_URL
|
- name: NEXT_PUBLIC_SITE_URL
|
||||||
value: "https://dev.manoonoils.com"
|
value: "https://dev.manoonoils.com"
|
||||||
|
- name: DASHBOARD_URL
|
||||||
|
value: "https://dashboard.manoonoils.com"
|
||||||
|
- name: RESEND_API_KEY
|
||||||
|
value: "re_bewcjHuy_DHtksWVUxguj8vFzKiJZNkFi"
|
||||||
|
- name: NEXT_PUBLIC_OPENPANEL_CLIENT_ID
|
||||||
|
value: "fa61f8ae-0b5d-4187-a9b1-5a04b0025674"
|
||||||
|
- name: OPENPANEL_CLIENT_SECRET
|
||||||
|
value: "91126be0d1e78e657e0427df82733832.c6d30edf6ee673da9650a883604169a13ab8579a0dde70cb39b477f4cf441f90"
|
||||||
|
- name: OPENPANEL_API_URL
|
||||||
|
value: "https://op.nodecrew.me/api"
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
cpu: 500m
|
cpu: 500m
|
||||||
|
|||||||
@@ -48,4 +48,4 @@ export const config = {
|
|||||||
"/(sr|en|de|fr)/:path*",
|
"/(sr|en|de|fr)/:path*",
|
||||||
"/((?!api|_next|_vercel|.*\\..*).*)",
|
"/((?!api|_next|_vercel|.*\\..*).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
3293
package-lock.json
generated
3293
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -6,10 +6,19 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^4.1.6",
|
"@apollo/client": "^4.1.6",
|
||||||
|
"@openpanel/nextjs": "^1.4.0",
|
||||||
|
"@react-email/components": "^1.0.10",
|
||||||
|
"@react-email/render": "^2.0.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"framer-motion": "^12.34.4",
|
"framer-motion": "^12.34.4",
|
||||||
"graphql": "^16.13.1",
|
"graphql": "^16.13.1",
|
||||||
@@ -18,17 +27,27 @@
|
|||||||
"next-intl": "^4.8.3",
|
"next-intl": "^4.8.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"resend": "^6.9.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"@vitest/coverage-v8": "^4.1.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
|
"msw": "^2.12.14",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
141
src/__tests__/README.md
Normal file
141
src/__tests__/README.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Manoon Storefront Test Suite
|
||||||
|
|
||||||
|
Comprehensive test suite for the ManoonOils storefront with focus on webhooks, commerce operations, and critical paths.
|
||||||
|
|
||||||
|
## 🎯 Coverage Goals
|
||||||
|
|
||||||
|
- **Critical Paths**: 80%+ coverage
|
||||||
|
- **Webhook Handlers**: 100% coverage
|
||||||
|
- **Email Services**: 90%+ coverage
|
||||||
|
- **Analytics**: 80%+ coverage
|
||||||
|
|
||||||
|
## 🧪 Test Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/__tests__/
|
||||||
|
├── unit/
|
||||||
|
│ ├── services/ # Business logic tests
|
||||||
|
│ │ ├── OrderNotificationService.test.ts
|
||||||
|
│ │ └── AnalyticsService.test.ts
|
||||||
|
│ ├── stores/ # State management tests
|
||||||
|
│ │ └── saleorCheckoutStore.test.ts
|
||||||
|
│ └── utils/ # Utility function tests
|
||||||
|
│ └── formatPrice.test.ts
|
||||||
|
├── integration/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── webhooks/
|
||||||
|
│ │ └── saleor.test.ts # Webhook handler tests
|
||||||
|
│ └── emails/
|
||||||
|
│ ├── OrderConfirmation.test.tsx
|
||||||
|
│ └── OrderShipped.test.tsx
|
||||||
|
└── fixtures/ # Test data
|
||||||
|
└── orders.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Running Tests
|
||||||
|
|
||||||
|
### Unit & Integration Tests (Vitest)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests in watch mode
|
||||||
|
npm test
|
||||||
|
|
||||||
|
# Run tests once
|
||||||
|
npm run test:run
|
||||||
|
|
||||||
|
# Run with coverage report
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# Run with UI
|
||||||
|
npm run test:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
### E2E Tests (Playwright)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all E2E tests
|
||||||
|
npm run test:e2e
|
||||||
|
|
||||||
|
# Run with UI mode
|
||||||
|
npm run test:e2e:ui
|
||||||
|
|
||||||
|
# Run specific test
|
||||||
|
npx playwright test tests/critical-paths/checkout-flow.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📊 Test Categories
|
||||||
|
|
||||||
|
### 🔥 Critical Tests (Must Pass)
|
||||||
|
|
||||||
|
1. **Webhook Handler Tests**
|
||||||
|
- ORDER_CONFIRMED: Sends emails + analytics
|
||||||
|
- ORDER_CREATED: No duplicate emails/analytics
|
||||||
|
- ORDER_FULFILLED: Tracking info included
|
||||||
|
- ORDER_CANCELLED: Cancellation reason included
|
||||||
|
- ORDER_FULLY_PAID: Payment confirmation
|
||||||
|
|
||||||
|
2. **Email Service Tests**
|
||||||
|
- Correct translations (SR, EN, DE, FR)
|
||||||
|
- Price formatting (no /100 bug)
|
||||||
|
- Admin vs Customer templates
|
||||||
|
- Address formatting
|
||||||
|
|
||||||
|
3. **Analytics Tests**
|
||||||
|
- Revenue tracked once per order
|
||||||
|
- Correct currency (RSD not USD)
|
||||||
|
- Error handling (doesn't break order flow)
|
||||||
|
|
||||||
|
### 🔧 Integration Tests
|
||||||
|
|
||||||
|
- Full checkout flow
|
||||||
|
- Cart operations
|
||||||
|
- Email template rendering
|
||||||
|
- API error handling
|
||||||
|
|
||||||
|
## 🎭 Mocking Strategy
|
||||||
|
|
||||||
|
- **Resend**: Mocked (no actual emails sent)
|
||||||
|
- **OpenPanel**: Mocked (no actual tracking in tests)
|
||||||
|
- **Saleor API**: Can use real instance for integration tests (read-only)
|
||||||
|
|
||||||
|
## 📈 Coverage Reports
|
||||||
|
|
||||||
|
Coverage reports are generated in multiple formats:
|
||||||
|
- Console output (text)
|
||||||
|
- `coverage/coverage-final.json` (JSON)
|
||||||
|
- `coverage/index.html` (HTML report)
|
||||||
|
|
||||||
|
Open `coverage/index.html` in browser for detailed view.
|
||||||
|
|
||||||
|
## 🔍 Debugging Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debug specific test
|
||||||
|
npm test -- --reporter=verbose src/__tests__/unit/services/AnalyticsService.test.ts
|
||||||
|
|
||||||
|
# Debug with logs
|
||||||
|
DEBUG=true npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 Adding New Tests
|
||||||
|
|
||||||
|
1. Create test file: `src/__tests__/unit|integration/path/to/file.test.ts`
|
||||||
|
2. Import from `@/` alias (configured in vitest.config.ts)
|
||||||
|
3. Use fixtures from `src/__tests__/fixtures/`
|
||||||
|
4. Mock external services
|
||||||
|
5. Run tests to verify
|
||||||
|
|
||||||
|
## 🚧 Current Limitations
|
||||||
|
|
||||||
|
- No CI/CD integration yet (informational only)
|
||||||
|
- E2E tests need Playwright browser installation
|
||||||
|
- Some tests use mocked data instead of real Saleor API
|
||||||
|
|
||||||
|
## ✅ Test Checklist
|
||||||
|
|
||||||
|
Before deploying, ensure:
|
||||||
|
- [ ] All webhook tests pass
|
||||||
|
- [ ] Email service tests pass
|
||||||
|
- [ ] Analytics tests pass
|
||||||
|
- [ ] Coverage >= 80% for critical paths
|
||||||
|
- [ ] No console errors in tests
|
||||||
112
src/__tests__/fixtures/orders.ts
Normal file
112
src/__tests__/fixtures/orders.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// Test fixtures for orders
|
||||||
|
export const mockOrderPayload = {
|
||||||
|
id: "T3JkZXI6MTIzNDU2Nzg=",
|
||||||
|
number: 1524,
|
||||||
|
user_email: "test@hytham.me",
|
||||||
|
first_name: "Test",
|
||||||
|
last_name: "Customer",
|
||||||
|
billing_address: {
|
||||||
|
first_name: "Test",
|
||||||
|
last_name: "Customer",
|
||||||
|
street_address_1: "123 Test Street",
|
||||||
|
street_address_2: "",
|
||||||
|
city: "Belgrade",
|
||||||
|
postal_code: "11000",
|
||||||
|
country: "RS",
|
||||||
|
phone: "+38160123456",
|
||||||
|
},
|
||||||
|
shipping_address: {
|
||||||
|
first_name: "Test",
|
||||||
|
last_name: "Customer",
|
||||||
|
street_address_1: "123 Test Street",
|
||||||
|
street_address_2: "",
|
||||||
|
city: "Belgrade",
|
||||||
|
postal_code: "11000",
|
||||||
|
country: "RS",
|
||||||
|
phone: "+38160123456",
|
||||||
|
},
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
id: "T3JkZXJMaW5lOjE=",
|
||||||
|
product_name: "Manoon Anti-age Serum",
|
||||||
|
variant_name: "50ml",
|
||||||
|
quantity: 2,
|
||||||
|
total_price_gross_amount: "10000",
|
||||||
|
currency: "RSD",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total_gross_amount: "10000",
|
||||||
|
shipping_price_gross_amount: "480",
|
||||||
|
channel: {
|
||||||
|
currency_code: "RSD",
|
||||||
|
},
|
||||||
|
language_code: "EN",
|
||||||
|
metadata: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockOrderConverted = {
|
||||||
|
id: "T3JkZXI6MTIzNDU2Nzg=",
|
||||||
|
number: "1524",
|
||||||
|
userEmail: "test@hytham.me",
|
||||||
|
user: {
|
||||||
|
firstName: "Test",
|
||||||
|
lastName: "Customer",
|
||||||
|
},
|
||||||
|
billingAddress: {
|
||||||
|
firstName: "Test",
|
||||||
|
lastName: "Customer",
|
||||||
|
streetAddress1: "123 Test Street",
|
||||||
|
streetAddress2: "",
|
||||||
|
city: "Belgrade",
|
||||||
|
postalCode: "11000",
|
||||||
|
country: "RS",
|
||||||
|
phone: "+38160123456",
|
||||||
|
},
|
||||||
|
shippingAddress: {
|
||||||
|
firstName: "Test",
|
||||||
|
lastName: "Customer",
|
||||||
|
streetAddress1: "123 Test Street",
|
||||||
|
streetAddress2: "",
|
||||||
|
city: "Belgrade",
|
||||||
|
postalCode: "11000",
|
||||||
|
country: "RS",
|
||||||
|
phone: "+38160123456",
|
||||||
|
},
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
id: "T3JkZXJMaW5lOjE=",
|
||||||
|
productName: "Manoon Anti-age Serum",
|
||||||
|
variantName: "50ml",
|
||||||
|
quantity: 2,
|
||||||
|
totalPrice: {
|
||||||
|
gross: {
|
||||||
|
amount: 10000,
|
||||||
|
currency: "RSD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: {
|
||||||
|
gross: {
|
||||||
|
amount: 10000,
|
||||||
|
currency: "RSD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
languageCode: "EN",
|
||||||
|
metadata: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockOrderWithTracking = {
|
||||||
|
...mockOrderPayload,
|
||||||
|
metadata: {
|
||||||
|
trackingNumber: "TRK123456789",
|
||||||
|
trackingUrl: "https://tracking.example.com/TRK123456789",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockOrderCancelled = {
|
||||||
|
...mockOrderPayload,
|
||||||
|
metadata: {
|
||||||
|
cancellationReason: "Customer requested cancellation",
|
||||||
|
},
|
||||||
|
};
|
||||||
280
src/__tests__/integration/api/webhooks/saleor.test.ts
Normal file
280
src/__tests__/integration/api/webhooks/saleor.test.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { POST, GET } from "@/app/api/webhooks/saleor/route";
|
||||||
|
import { orderNotificationService } from "@/lib/services/OrderNotificationService";
|
||||||
|
import { analyticsService } from "@/lib/services/AnalyticsService";
|
||||||
|
import { mockOrderPayload, mockOrderWithTracking, mockOrderCancelled } from "../../../fixtures/orders";
|
||||||
|
|
||||||
|
// Mock the services
|
||||||
|
vi.mock("@/lib/services/OrderNotificationService", () => ({
|
||||||
|
orderNotificationService: {
|
||||||
|
sendOrderConfirmation: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendOrderConfirmationToAdmin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendOrderShipped: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendOrderShippedToAdmin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendOrderCancelled: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendOrderCancelledToAdmin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendOrderPaid: vi.fn().mockResolvedValue(undefined),
|
||||||
|
sendOrderPaidToAdmin: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/services/AnalyticsService", () => ({
|
||||||
|
analyticsService: {
|
||||||
|
trackOrderReceived: vi.fn().mockResolvedValue(undefined),
|
||||||
|
trackRevenue: vi.fn().mockResolvedValue(undefined),
|
||||||
|
track: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Saleor Webhook Handler", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/webhooks/saleor", () => {
|
||||||
|
it("should return health check response", async () => {
|
||||||
|
const response = await GET();
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.status).toBe("ok");
|
||||||
|
expect(data.supportedEvents).toContain("ORDER_CONFIRMED");
|
||||||
|
expect(data.supportedEvents).toContain("ORDER_CREATED");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/webhooks/saleor - ORDER_CONFIRMED", () => {
|
||||||
|
it("should process ORDER_CONFIRMED and send customer + admin emails", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_CONFIRMED",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderPayload]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
|
||||||
|
// Should send customer email
|
||||||
|
expect(orderNotificationService.sendOrderConfirmation).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Should send admin email
|
||||||
|
expect(orderNotificationService.sendOrderConfirmationToAdmin).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Should track analytics
|
||||||
|
expect(analyticsService.trackOrderReceived).toHaveBeenCalledTimes(1);
|
||||||
|
expect(analyticsService.trackRevenue).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Verify revenue tracking has correct data
|
||||||
|
expect(analyticsService.trackRevenue).toHaveBeenCalledWith({
|
||||||
|
amount: 10000,
|
||||||
|
currency: "RSD",
|
||||||
|
orderId: mockOrderPayload.id,
|
||||||
|
orderNumber: String(mockOrderPayload.number),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT track analytics for ORDER_CREATED (prevents duplication)", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_CREATED",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderPayload]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
// Should NOT send customer email
|
||||||
|
expect(orderNotificationService.sendOrderConfirmation).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Should NOT track analytics
|
||||||
|
expect(analyticsService.trackOrderReceived).not.toHaveBeenCalled();
|
||||||
|
expect(analyticsService.trackRevenue).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Should still send admin notification
|
||||||
|
expect(orderNotificationService.sendOrderConfirmationToAdmin).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/webhooks/saleor - ORDER_FULFILLED", () => {
|
||||||
|
it("should send shipping emails with tracking info", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_FULFILLED",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderWithTracking]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
expect(orderNotificationService.sendOrderShipped).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
"TRK123456789",
|
||||||
|
"https://tracking.example.com/TRK123456789"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(orderNotificationService.sendOrderShippedToAdmin).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
"TRK123456789",
|
||||||
|
"https://tracking.example.com/TRK123456789"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/webhooks/saleor - ORDER_CANCELLED", () => {
|
||||||
|
it("should send cancellation emails with reason", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_CANCELLED",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderCancelled]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
expect(orderNotificationService.sendOrderCancelled).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
"Customer requested cancellation"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(orderNotificationService.sendOrderCancelledToAdmin).toHaveBeenCalledWith(
|
||||||
|
expect.any(Object),
|
||||||
|
"Customer requested cancellation"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/webhooks/saleor - ORDER_FULLY_PAID", () => {
|
||||||
|
it("should send payment confirmation emails", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_FULLY_PAID",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderPayload]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
|
||||||
|
expect(orderNotificationService.sendOrderPaid).toHaveBeenCalledTimes(1);
|
||||||
|
expect(orderNotificationService.sendOrderPaidToAdmin).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error Handling", () => {
|
||||||
|
it("should return 400 for missing order in payload", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_CONFIRMED",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([]), // Empty array
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(data.error).toBe("No order in payload");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 400 for missing saleor-event header", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderPayload]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(data.error).toBe("Missing saleor-event header");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 200 for unsupported events (graceful skip)", async () => {
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "UNSUPPORTED_EVENT",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderPayload]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(data.message).toBe("Event not supported");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle server errors gracefully", async () => {
|
||||||
|
// Simulate service throwing error
|
||||||
|
vi.mocked(orderNotificationService.sendOrderConfirmationToAdmin).mockRejectedValueOnce(
|
||||||
|
new Error("Email service down")
|
||||||
|
);
|
||||||
|
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_CREATED",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([mockOrderPayload]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await POST(request);
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Currency Handling", () => {
|
||||||
|
it("should preserve RSD currency from Saleor payload", async () => {
|
||||||
|
const rsdOrder = {
|
||||||
|
...mockOrderPayload,
|
||||||
|
total_gross_amount: "5479",
|
||||||
|
channel: { currency_code: "RSD" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = new NextRequest("http://localhost:3000/api/webhooks/saleor", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"saleor-event": "ORDER_CONFIRMED",
|
||||||
|
"saleor-domain": "api.manoonoils.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify([rsdOrder]),
|
||||||
|
});
|
||||||
|
|
||||||
|
await POST(request);
|
||||||
|
|
||||||
|
// Verify the order passed to analytics has correct currency
|
||||||
|
expect(analyticsService.trackRevenue).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
amount: 5479,
|
||||||
|
currency: "RSD",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
35
src/__tests__/setup.ts
Normal file
35
src/__tests__/setup.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { vi } from "vitest";
|
||||||
|
|
||||||
|
// Mock environment variables
|
||||||
|
process.env.NEXT_PUBLIC_SALEOR_API_URL = "https://api.manoonoils.com/graphql/";
|
||||||
|
process.env.NEXT_PUBLIC_SITE_URL = "https://dev.manoonoils.com";
|
||||||
|
process.env.DASHBOARD_URL = "https://dashboard.manoonoils.com";
|
||||||
|
process.env.RESEND_API_KEY = "test-api-key";
|
||||||
|
process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID = "test-client-id";
|
||||||
|
process.env.OPENPANEL_CLIENT_SECRET = "test-client-secret";
|
||||||
|
process.env.OPENPANEL_API_URL = "https://op.nodecrew.me/api";
|
||||||
|
|
||||||
|
// Mock Resend
|
||||||
|
vi.mock("resend", () => ({
|
||||||
|
Resend: vi.fn().mockImplementation(() => ({
|
||||||
|
emails: {
|
||||||
|
send: vi.fn().mockResolvedValue({ id: "test-email-id" }),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock OpenPanel
|
||||||
|
vi.mock("@openpanel/nextjs", () => ({
|
||||||
|
OpenPanel: vi.fn().mockImplementation(() => ({
|
||||||
|
track: vi.fn().mockResolvedValue(undefined),
|
||||||
|
revenue: vi.fn().mockResolvedValue(undefined),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Global test utilities
|
||||||
|
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||||
|
observe: vi.fn(),
|
||||||
|
unobserve: vi.fn(),
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
}));
|
||||||
233
src/__tests__/unit/services/AnalyticsService.test.ts
Normal file
233
src/__tests__/unit/services/AnalyticsService.test.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// Create mock functions using vi.hoisted so they're available during mock setup
|
||||||
|
const { mockTrack, mockRevenue } = vi.hoisted(() => ({
|
||||||
|
mockTrack: vi.fn().mockResolvedValue(undefined),
|
||||||
|
mockRevenue: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock OpenPanel using factory function
|
||||||
|
vi.mock("@openpanel/nextjs", () => {
|
||||||
|
return {
|
||||||
|
OpenPanel: class MockOpenPanel {
|
||||||
|
track = mockTrack;
|
||||||
|
revenue = mockRevenue;
|
||||||
|
constructor() {}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import after mock is set up
|
||||||
|
import { AnalyticsService } from "@/lib/services/AnalyticsService";
|
||||||
|
|
||||||
|
describe("AnalyticsService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("trackOrderReceived", () => {
|
||||||
|
it("should track order with all details", async () => {
|
||||||
|
await new AnalyticsService().trackOrderReceived({
|
||||||
|
orderId: "order-123",
|
||||||
|
orderNumber: "1524",
|
||||||
|
total: 5479,
|
||||||
|
currency: "RSD",
|
||||||
|
itemCount: 3,
|
||||||
|
customerEmail: "test@example.com",
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTrack).toHaveBeenCalledWith("order_received", {
|
||||||
|
order_id: "order-123",
|
||||||
|
order_number: "1524",
|
||||||
|
total: 5479,
|
||||||
|
currency: "RSD",
|
||||||
|
item_count: 3,
|
||||||
|
customer_email: "test@example.com",
|
||||||
|
event_type: "ORDER_CONFIRMED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle large order values", async () => {
|
||||||
|
await new AnalyticsService().trackOrderReceived({
|
||||||
|
orderId: "order-456",
|
||||||
|
orderNumber: "2000",
|
||||||
|
total: 500000, // Large amount
|
||||||
|
currency: "RSD",
|
||||||
|
itemCount: 100,
|
||||||
|
customerEmail: "bulk@example.com",
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTrack).toHaveBeenCalledWith(
|
||||||
|
"order_received",
|
||||||
|
expect.objectContaining({
|
||||||
|
total: 500000,
|
||||||
|
item_count: 100,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw if tracking fails", async () => {
|
||||||
|
mockTrack.mockRejectedValueOnce(new Error("Network error"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
new AnalyticsService().trackOrderReceived({
|
||||||
|
orderId: "order-123",
|
||||||
|
orderNumber: "1524",
|
||||||
|
total: 1000,
|
||||||
|
currency: "RSD",
|
||||||
|
itemCount: 1,
|
||||||
|
customerEmail: "test@example.com",
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
})
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("trackRevenue", () => {
|
||||||
|
it("should track revenue with correct currency", async () => {
|
||||||
|
await new AnalyticsService().trackRevenue({
|
||||||
|
amount: 5479,
|
||||||
|
currency: "RSD",
|
||||||
|
orderId: "order-123",
|
||||||
|
orderNumber: "1524",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRevenue).toHaveBeenCalledWith(5479, {
|
||||||
|
currency: "RSD",
|
||||||
|
order_id: "order-123",
|
||||||
|
order_number: "1524",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should track revenue with different currencies", async () => {
|
||||||
|
// Test EUR
|
||||||
|
await new AnalyticsService().trackRevenue({
|
||||||
|
amount: 100,
|
||||||
|
currency: "EUR",
|
||||||
|
orderId: "order-1",
|
||||||
|
orderNumber: "1000",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRevenue).toHaveBeenCalledWith(100, {
|
||||||
|
currency: "EUR",
|
||||||
|
order_id: "order-1",
|
||||||
|
order_number: "1000",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test USD
|
||||||
|
await new AnalyticsService().trackRevenue({
|
||||||
|
amount: 150,
|
||||||
|
currency: "USD",
|
||||||
|
orderId: "order-2",
|
||||||
|
orderNumber: "1001",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRevenue).toHaveBeenCalledWith(150, {
|
||||||
|
currency: "USD",
|
||||||
|
order_id: "order-2",
|
||||||
|
order_number: "1001",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should log tracking for debugging", async () => {
|
||||||
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await new AnalyticsService().trackRevenue({
|
||||||
|
amount: 5479,
|
||||||
|
currency: "RSD",
|
||||||
|
orderId: "order-123",
|
||||||
|
orderNumber: "1524",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
"Tracking revenue: 5479 RSD for order 1524"
|
||||||
|
);
|
||||||
|
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw if revenue tracking fails", async () => {
|
||||||
|
mockRevenue.mockRejectedValueOnce(new Error("API error"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
new AnalyticsService().trackRevenue({
|
||||||
|
amount: 1000,
|
||||||
|
currency: "RSD",
|
||||||
|
orderId: "order-123",
|
||||||
|
orderNumber: "1524",
|
||||||
|
})
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle zero amount orders", async () => {
|
||||||
|
await new AnalyticsService().trackRevenue({
|
||||||
|
amount: 0,
|
||||||
|
currency: "RSD",
|
||||||
|
orderId: "order-000",
|
||||||
|
orderNumber: "0000",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockRevenue).toHaveBeenCalledWith(0, {
|
||||||
|
currency: "RSD",
|
||||||
|
order_id: "order-000",
|
||||||
|
order_number: "0000",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("track", () => {
|
||||||
|
it("should track custom events", async () => {
|
||||||
|
await new AnalyticsService().track("custom_event", {
|
||||||
|
property1: "value1",
|
||||||
|
property2: 123,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTrack).toHaveBeenCalledWith("custom_event", {
|
||||||
|
property1: "value1",
|
||||||
|
property2: 123,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not throw on tracking errors", async () => {
|
||||||
|
mockTrack.mockRejectedValueOnce(new Error("Tracking failed"));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
new AnalyticsService().track("test_event", { test: true })
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Singleton pattern", () => {
|
||||||
|
it("should return the same instance", async () => {
|
||||||
|
// Import fresh to test singleton using dynamic import
|
||||||
|
const { analyticsService: service1 } = await import("@/lib/services/AnalyticsService");
|
||||||
|
const { analyticsService: service2 } = await import("@/lib/services/AnalyticsService");
|
||||||
|
|
||||||
|
expect(service1).toBe(service2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error handling", () => {
|
||||||
|
it("should log errors but not throw", async () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
mockTrack.mockRejectedValueOnce(new Error("Test error"));
|
||||||
|
|
||||||
|
await new AnalyticsService().trackOrderReceived({
|
||||||
|
orderId: "order-123",
|
||||||
|
orderNumber: "1524",
|
||||||
|
total: 1000,
|
||||||
|
currency: "RSD",
|
||||||
|
itemCount: 1,
|
||||||
|
customerEmail: "test@example.com",
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||||
|
expect(consoleErrorSpy.mock.calls[0][0]).toContain("Failed to track order received");
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
263
src/__tests__/unit/services/OrderNotificationService.test.ts
Normal file
263
src/__tests__/unit/services/OrderNotificationService.test.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { orderNotificationService } from "@/lib/services/OrderNotificationService";
|
||||||
|
import { sendEmailToCustomer, sendEmailToAdmin } from "@/lib/resend";
|
||||||
|
import { mockOrderConverted } from "../../fixtures/orders";
|
||||||
|
|
||||||
|
// Mock the resend module
|
||||||
|
vi.mock("@/lib/resend", () => ({
|
||||||
|
sendEmailToCustomer: vi.fn().mockResolvedValue({ id: "test-email-id" }),
|
||||||
|
sendEmailToAdmin: vi.fn().mockResolvedValue({ id: "test-email-id" }),
|
||||||
|
ADMIN_EMAILS: ["me@hytham.me", "tamara@hytham.me"],
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("OrderNotificationService", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendOrderConfirmation", () => {
|
||||||
|
it("should send customer order confirmation in correct language (EN)", async () => {
|
||||||
|
const order = { ...mockOrderConverted, languageCode: "EN" };
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "test@hytham.me",
|
||||||
|
subject: "Order Confirmation #1524",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send customer order confirmation in Serbian (SR)", async () => {
|
||||||
|
const order = { ...mockOrderConverted, languageCode: "SR" };
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "test@hytham.me",
|
||||||
|
subject: "Potvrda narudžbine #1524",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send customer order confirmation in German (DE)", async () => {
|
||||||
|
const order = { ...mockOrderConverted, languageCode: "DE" };
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "test@hytham.me",
|
||||||
|
subject: "Bestellbestätigung #1524",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send customer order confirmation in French (FR)", async () => {
|
||||||
|
const order = { ...mockOrderConverted, languageCode: "FR" };
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "test@hytham.me",
|
||||||
|
subject: "Confirmation de commande #1524",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format price correctly", async () => {
|
||||||
|
const order = {
|
||||||
|
...mockOrderConverted,
|
||||||
|
total: {
|
||||||
|
gross: {
|
||||||
|
amount: 5479,
|
||||||
|
currency: "RSD",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
subject: "Order Confirmation #1524",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle missing variant name gracefully", async () => {
|
||||||
|
const order = {
|
||||||
|
...mockOrderConverted,
|
||||||
|
lines: [
|
||||||
|
{
|
||||||
|
...mockOrderConverted.lines[0],
|
||||||
|
variantName: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include variant name when present", async () => {
|
||||||
|
await orderNotificationService.sendOrderConfirmation(mockOrderConverted);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendOrderConfirmationToAdmin", () => {
|
||||||
|
it("should send admin notification with order details", async () => {
|
||||||
|
await orderNotificationService.sendOrderConfirmationToAdmin(mockOrderConverted);
|
||||||
|
|
||||||
|
expect(sendEmailToAdmin).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
subject: expect.stringContaining("🎉 New Order #1524"),
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
orderId: "T3JkZXI6MTIzNDU2Nzg=",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should always use English for admin emails", async () => {
|
||||||
|
const order = { ...mockOrderConverted, languageCode: "SR" };
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmationToAdmin(order);
|
||||||
|
|
||||||
|
expect(sendEmailToAdmin).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include all order details in admin email", async () => {
|
||||||
|
await orderNotificationService.sendOrderConfirmationToAdmin(mockOrderConverted);
|
||||||
|
|
||||||
|
expect(sendEmailToAdmin).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
subject: expect.stringContaining("🎉 New Order"),
|
||||||
|
eventType: "ORDER_CONFIRMED",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendOrderShipped", () => {
|
||||||
|
it("should send shipping confirmation with tracking", async () => {
|
||||||
|
await orderNotificationService.sendOrderShipped(
|
||||||
|
mockOrderConverted,
|
||||||
|
"TRK123",
|
||||||
|
"https://track.com/TRK123"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "test@hytham.me",
|
||||||
|
subject: "Your Order #1524 Has Shipped!",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle missing tracking info", async () => {
|
||||||
|
await orderNotificationService.sendOrderShipped(mockOrderConverted);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
subject: "Your Order #1524 Has Shipped!",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendOrderCancelled", () => {
|
||||||
|
it("should send cancellation email with reason", async () => {
|
||||||
|
await orderNotificationService.sendOrderCancelled(
|
||||||
|
mockOrderConverted,
|
||||||
|
"Out of stock"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "test@hytham.me",
|
||||||
|
subject: "Your Order #1524 Has Been Cancelled",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sendOrderPaid", () => {
|
||||||
|
it("should send payment confirmation", async () => {
|
||||||
|
await orderNotificationService.sendOrderPaid(mockOrderConverted);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
to: "test@hytham.me",
|
||||||
|
subject: "Payment Received for Order #1524!",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatPrice", () => {
|
||||||
|
it("should format prices correctly for RSD", () => {
|
||||||
|
// This is tested indirectly through the email calls above
|
||||||
|
// The formatPrice function is in utils.ts
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("edge cases", () => {
|
||||||
|
it("should handle orders with user name", async () => {
|
||||||
|
const order = {
|
||||||
|
...mockOrderConverted,
|
||||||
|
user: { firstName: "John", lastName: "Doe" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle orders without user object", async () => {
|
||||||
|
const order = {
|
||||||
|
...mockOrderConverted,
|
||||||
|
user: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle orders with incomplete address", async () => {
|
||||||
|
const order = {
|
||||||
|
...mockOrderConverted,
|
||||||
|
shippingAddress: {
|
||||||
|
firstName: "Test",
|
||||||
|
lastName: "Customer",
|
||||||
|
city: "Belgrade",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle orders with missing shipping address", async () => {
|
||||||
|
const order = {
|
||||||
|
...mockOrderConverted,
|
||||||
|
shippingAddress: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await orderNotificationService.sendOrderConfirmation(order);
|
||||||
|
|
||||||
|
expect(sendEmailToCustomer).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
42
src/__tests__/unit/utils/formatPrice.test.ts
Normal file
42
src/__tests__/unit/utils/formatPrice.test.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { formatPrice } from "@/app/api/webhooks/saleor/utils";
|
||||||
|
|
||||||
|
describe("formatPrice", () => {
|
||||||
|
it("should format RSD currency correctly", () => {
|
||||||
|
const result = formatPrice(5479, "RSD");
|
||||||
|
// Note: sr-RS locale uses non-breaking space between number and currency
|
||||||
|
expect(result).toMatch(/5\.479,00\sRSD/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format small amounts correctly", () => {
|
||||||
|
const result = formatPrice(50, "RSD");
|
||||||
|
expect(result).toMatch(/50,00\sRSD/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format large amounts correctly", () => {
|
||||||
|
const result = formatPrice(100000, "RSD");
|
||||||
|
expect(result).toMatch(/100\.000,00\sRSD/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format EUR currency correctly", () => {
|
||||||
|
const result = formatPrice(100, "EUR");
|
||||||
|
// sr-RS locale uses € symbol for EUR
|
||||||
|
expect(result).toMatch(/100,00\s€/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should format USD currency correctly", () => {
|
||||||
|
const result = formatPrice(150, "USD");
|
||||||
|
// sr-RS locale uses US$ symbol for USD
|
||||||
|
expect(result).toMatch(/150,00\sUS\$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle decimal amounts", () => {
|
||||||
|
const result = formatPrice(1000.5, "RSD");
|
||||||
|
expect(result).toMatch(/1\.000,50\sRSD/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle zero", () => {
|
||||||
|
const result = formatPrice(0, "RSD");
|
||||||
|
expect(result).toMatch(/0,00\sRSD/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,7 +45,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
|||||||
<div className="relative h-[400px] md:h-[500px] overflow-hidden">
|
<div className="relative h-[400px] md:h-[500px] overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop"
|
src="https://images.unsplash.com/photo-1608571423902-eed4a5ad8108?q=80&w=2000&auto=format&fit=crop"
|
||||||
alt={metadata.productionAlt}
|
alt={metadata.about.productionAlt}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-black/20" />
|
<div className="absolute inset-0 bg-black/20" />
|
||||||
|
|||||||
@@ -10,11 +10,16 @@ import Footer from "@/components/layout/Footer";
|
|||||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||||
import { formatPrice } from "@/lib/saleor";
|
import { formatPrice } from "@/lib/saleor";
|
||||||
import { saleorClient } from "@/lib/saleor/client";
|
import { saleorClient } from "@/lib/saleor/client";
|
||||||
|
import { useAnalytics } from "@/lib/analytics";
|
||||||
import {
|
import {
|
||||||
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||||
CHECKOUT_BILLING_ADDRESS_UPDATE,
|
CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||||
CHECKOUT_COMPLETE,
|
CHECKOUT_COMPLETE,
|
||||||
|
CHECKOUT_EMAIL_UPDATE,
|
||||||
|
CHECKOUT_METADATA_UPDATE,
|
||||||
|
CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||||
} from "@/lib/saleor/mutations/Checkout";
|
} from "@/lib/saleor/mutations/Checkout";
|
||||||
|
import { GET_CHECKOUT_BY_ID } from "@/lib/saleor/queries/Checkout";
|
||||||
import type { Checkout } from "@/types/saleor";
|
import type { Checkout } from "@/types/saleor";
|
||||||
|
|
||||||
interface ShippingAddressUpdateResponse {
|
interface ShippingAddressUpdateResponse {
|
||||||
@@ -38,6 +43,43 @@ interface CheckoutCompleteResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EmailUpdateResponse {
|
||||||
|
checkoutEmailUpdate?: {
|
||||||
|
checkout?: Checkout;
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetadataUpdateResponse {
|
||||||
|
updateMetadata?: {
|
||||||
|
item?: {
|
||||||
|
id: string;
|
||||||
|
metadata?: Array<{ key: string; value: string }>;
|
||||||
|
};
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShippingMethodUpdateResponse {
|
||||||
|
checkoutShippingMethodUpdate?: {
|
||||||
|
checkout?: Checkout;
|
||||||
|
errors?: Array<{ message: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CheckoutQueryResponse {
|
||||||
|
checkout?: Checkout;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShippingMethod {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface AddressForm {
|
interface AddressForm {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
@@ -45,7 +87,9 @@ interface AddressForm {
|
|||||||
streetAddress2: string;
|
streetAddress2: string;
|
||||||
city: string;
|
city: string;
|
||||||
postalCode: string;
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CheckoutPage() {
|
export default function CheckoutPage() {
|
||||||
@@ -53,6 +97,7 @@ export default function CheckoutPage() {
|
|||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore();
|
const { checkout, refreshCheckout, getLines, getTotal } = useSaleorCheckoutStore();
|
||||||
|
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);
|
||||||
const [orderComplete, setOrderComplete] = useState(false);
|
const [orderComplete, setOrderComplete] = useState(false);
|
||||||
@@ -66,7 +111,9 @@ export default function CheckoutPage() {
|
|||||||
streetAddress2: "",
|
streetAddress2: "",
|
||||||
city: "",
|
city: "",
|
||||||
postalCode: "",
|
postalCode: "",
|
||||||
|
country: "RS",
|
||||||
phone: "",
|
phone: "",
|
||||||
|
email: "",
|
||||||
});
|
});
|
||||||
const [billingAddress, setBillingAddress] = useState<AddressForm>({
|
const [billingAddress, setBillingAddress] = useState<AddressForm>({
|
||||||
firstName: "",
|
firstName: "",
|
||||||
@@ -75,21 +122,120 @@ export default function CheckoutPage() {
|
|||||||
streetAddress2: "",
|
streetAddress2: "",
|
||||||
city: "",
|
city: "",
|
||||||
postalCode: "",
|
postalCode: "",
|
||||||
|
country: "RS",
|
||||||
phone: "",
|
phone: "",
|
||||||
|
email: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
|
||||||
|
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
|
||||||
|
const [isLoadingShipping, setIsLoadingShipping] = useState(false);
|
||||||
|
|
||||||
const lines = getLines();
|
const lines = getLines();
|
||||||
const total = getTotal();
|
const total = getTotal();
|
||||||
|
|
||||||
|
// Debounced shipping method fetching
|
||||||
|
useEffect(() => {
|
||||||
|
if (!checkout) return;
|
||||||
|
|
||||||
|
// Check if address is complete enough to fetch shipping methods
|
||||||
|
const isAddressComplete =
|
||||||
|
shippingAddress.firstName &&
|
||||||
|
shippingAddress.lastName &&
|
||||||
|
shippingAddress.streetAddress1 &&
|
||||||
|
shippingAddress.city &&
|
||||||
|
shippingAddress.postalCode &&
|
||||||
|
shippingAddress.country;
|
||||||
|
|
||||||
|
if (!isAddressComplete) {
|
||||||
|
setShippingMethods([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
setIsLoadingShipping(true);
|
||||||
|
try {
|
||||||
|
console.log("Fetching shipping methods...");
|
||||||
|
|
||||||
|
// First update the shipping address
|
||||||
|
await saleorClient.mutate<ShippingAddressUpdateResponse>({
|
||||||
|
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||||
|
variables: {
|
||||||
|
checkoutId: checkout.id,
|
||||||
|
shippingAddress: {
|
||||||
|
firstName: shippingAddress.firstName,
|
||||||
|
lastName: shippingAddress.lastName,
|
||||||
|
streetAddress1: shippingAddress.streetAddress1,
|
||||||
|
streetAddress2: shippingAddress.streetAddress2,
|
||||||
|
city: shippingAddress.city,
|
||||||
|
postalCode: shippingAddress.postalCode,
|
||||||
|
country: shippingAddress.country,
|
||||||
|
phone: shippingAddress.phone,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then query for shipping methods
|
||||||
|
const checkoutQueryResult = await saleorClient.query<CheckoutQueryResponse>({
|
||||||
|
query: GET_CHECKOUT_BY_ID,
|
||||||
|
variables: { id: checkout.id },
|
||||||
|
fetchPolicy: "network-only",
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableMethods = checkoutQueryResult.data?.checkout?.shippingMethods || [];
|
||||||
|
console.log("Available shipping methods:", availableMethods);
|
||||||
|
|
||||||
|
setShippingMethods(availableMethods);
|
||||||
|
|
||||||
|
// Auto-select first method if none selected
|
||||||
|
if (availableMethods.length > 0 && !selectedShippingMethod) {
|
||||||
|
setSelectedShippingMethod(availableMethods[0].id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching shipping methods:", err);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingShipping(false);
|
||||||
|
}
|
||||||
|
}, 500); // 500ms debounce
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [checkout, shippingAddress]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!checkout) {
|
if (!checkout) {
|
||||||
refreshCheckout();
|
refreshCheckout();
|
||||||
}
|
}
|
||||||
}, [checkout, refreshCheckout]);
|
}, [checkout, refreshCheckout]);
|
||||||
|
|
||||||
|
// Track checkout started when page loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (checkout) {
|
||||||
|
const lines = getLines();
|
||||||
|
const total = getTotal();
|
||||||
|
trackCheckoutStarted({
|
||||||
|
total,
|
||||||
|
currency: "RSD",
|
||||||
|
item_count: lines.reduce((sum, line) => sum + line.quantity, 0),
|
||||||
|
items: lines.map(line => ({
|
||||||
|
id: line.variant.id,
|
||||||
|
name: line.variant.product.name,
|
||||||
|
quantity: line.quantity,
|
||||||
|
price: line.variant.pricing?.price?.gross?.amount || 0,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [checkout]);
|
||||||
|
|
||||||
|
// Scroll to top when order is complete
|
||||||
|
useEffect(() => {
|
||||||
|
if (orderComplete) {
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, [orderComplete]);
|
||||||
|
|
||||||
const handleShippingChange = (field: keyof AddressForm, value: string) => {
|
const handleShippingChange = (field: keyof AddressForm, value: string) => {
|
||||||
setShippingAddress((prev) => ({ ...prev, [field]: value }));
|
setShippingAddress((prev) => ({ ...prev, [field]: value }));
|
||||||
if (sameAsShipping) {
|
if (sameAsShipping && field !== "email") {
|
||||||
setBillingAddress((prev) => ({ ...prev, [field]: value }));
|
setBillingAddress((prev) => ({ ...prev, [field]: value }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -98,6 +244,10 @@ export default function CheckoutPage() {
|
|||||||
setBillingAddress((prev) => ({ ...prev, [field]: value }));
|
setBillingAddress((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEmailChange = (value: string) => {
|
||||||
|
setShippingAddress((prev) => ({ ...prev, email: value }));
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -106,61 +256,164 @@ export default function CheckoutPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate all required fields
|
||||||
|
if (!shippingAddress.email || !shippingAddress.email.includes("@")) {
|
||||||
|
setError(t("errorEmailRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shippingAddress.phone || shippingAddress.phone.length < 8) {
|
||||||
|
setError(t("errorPhoneRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shippingAddress.firstName || !shippingAddress.lastName || !shippingAddress.streetAddress1 || !shippingAddress.city || !shippingAddress.postalCode) {
|
||||||
|
setError(t("errorFieldsRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedShippingMethod) {
|
||||||
|
setError(t("errorSelectShipping"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shippingResult = await saleorClient.mutate<ShippingAddressUpdateResponse>({
|
console.log("Completing order...");
|
||||||
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
|
||||||
variables: {
|
console.log("Step 1: Updating email...");
|
||||||
checkoutId: checkout.id,
|
const emailResult = await saleorClient.mutate<EmailUpdateResponse>({
|
||||||
shippingAddress: {
|
mutation: CHECKOUT_EMAIL_UPDATE,
|
||||||
...shippingAddress,
|
variables: {
|
||||||
country: "RS",
|
checkoutId: checkout.id,
|
||||||
|
email: shippingAddress.email,
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
|
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
|
||||||
throw new Error(shippingResult.data.checkoutShippingAddressUpdate.errors[0].message);
|
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");
|
||||||
|
|
||||||
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
console.log("Step 2: Updating billing address...");
|
||||||
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
|
||||||
variables: {
|
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||||
checkoutId: checkout.id,
|
variables: {
|
||||||
billingAddress: {
|
checkoutId: checkout.id,
|
||||||
...billingAddress,
|
billingAddress: {
|
||||||
country: "RS",
|
firstName: billingAddress.firstName,
|
||||||
|
lastName: billingAddress.lastName,
|
||||||
|
streetAddress1: billingAddress.streetAddress1,
|
||||||
|
streetAddress2: billingAddress.streetAddress2,
|
||||||
|
city: billingAddress.city,
|
||||||
|
postalCode: billingAddress.postalCode,
|
||||||
|
country: billingAddress.country,
|
||||||
|
phone: billingAddress.phone,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
|
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
|
||||||
throw new Error(billingResult.data.checkoutBillingAddressUpdate.errors[0].message);
|
throw new Error(`Billing address update failed: ${billingResult.data.checkoutBillingAddressUpdate.errors[0].message}`);
|
||||||
}
|
}
|
||||||
|
console.log("Step 2: Billing address updated successfully");
|
||||||
|
|
||||||
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
|
console.log("Step 3: Setting shipping method...");
|
||||||
mutation: CHECKOUT_COMPLETE,
|
const shippingMethodResult = await saleorClient.mutate<ShippingMethodUpdateResponse>({
|
||||||
variables: {
|
mutation: CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||||
checkoutId: checkout.id,
|
variables: {
|
||||||
},
|
checkoutId: checkout.id,
|
||||||
});
|
shippingMethodId: selectedShippingMethod,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (completeResult.data?.checkoutComplete?.errors && completeResult.data.checkoutComplete.errors.length > 0) {
|
if (shippingMethodResult.data?.checkoutShippingMethodUpdate?.errors && shippingMethodResult.data.checkoutShippingMethodUpdate.errors.length > 0) {
|
||||||
throw new Error(completeResult.data.checkoutComplete.errors[0].message);
|
throw new Error(`Shipping method update failed: ${shippingMethodResult.data.checkoutShippingMethodUpdate.errors[0].message}`);
|
||||||
}
|
}
|
||||||
|
console.log("Step 3: Shipping method set successfully");
|
||||||
|
|
||||||
const order = completeResult.data?.checkoutComplete?.order;
|
console.log("Step 4: Saving metadata...");
|
||||||
if (order) {
|
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
|
||||||
setOrderNumber(order.number);
|
mutation: CHECKOUT_METADATA_UPDATE,
|
||||||
setOrderComplete(true);
|
variables: {
|
||||||
} else {
|
checkoutId: checkout.id,
|
||||||
throw new Error(t("errorCreatingOrder"));
|
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) {
|
||||||
const errorMessage = err instanceof Error ? err.message : null;
|
console.error("Checkout error:", err);
|
||||||
setError(errorMessage || t("errorOccurred"));
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.name === "AbortError") {
|
||||||
|
setError("Request timed out. Please check your connection and try again.");
|
||||||
|
} else {
|
||||||
|
setError(err.message || t("errorOccurred"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(t("errorOccurred"));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -227,6 +480,36 @@ export default function CheckoutPage() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||||
<div>
|
<div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="border-b border-border pb-6">
|
||||||
|
<h2 className="text-xl font-serif mb-4">{t("contactInfo")}</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("email")}</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={shippingAddress.email}
|
||||||
|
onChange={(e) => handleEmailChange(e.target.value)}
|
||||||
|
className="w-full border border-border px-4 py-2 rounded"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-foreground-muted mt-1">{t("emailRequired")}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("phone")}</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
required
|
||||||
|
value={shippingAddress.phone}
|
||||||
|
onChange={(e) => handleShippingChange("phone", e.target.value)}
|
||||||
|
className="w-full border border-border px-4 py-2 rounded"
|
||||||
|
placeholder="+381..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-foreground-muted mt-1">{t("phoneRequired")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-b border-border pb-6">
|
<div className="border-b border-border pb-6">
|
||||||
<h2 className="text-xl font-serif mb-4">{t("shippingAddress")}</h2>
|
<h2 className="text-xl font-serif mb-4">{t("shippingAddress")}</h2>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -250,6 +533,35 @@ export default function CheckoutPage() {
|
|||||||
className="w-full border border-border px-4 py-2 rounded"
|
className="w-full border border-border px-4 py-2 rounded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium mb-1">{t("country")}</label>
|
||||||
|
<select
|
||||||
|
required
|
||||||
|
value={shippingAddress.country}
|
||||||
|
onChange={(e) => handleShippingChange("country", e.target.value)}
|
||||||
|
className="w-full border border-border px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
<option value="RS">Serbia (Srbija)</option>
|
||||||
|
<option value="BA">Bosnia and Herzegovina</option>
|
||||||
|
<option value="ME">Montenegro</option>
|
||||||
|
<option value="HR">Croatia</option>
|
||||||
|
<option value="SI">Slovenia</option>
|
||||||
|
<option value="MK">North Macedonia</option>
|
||||||
|
<option value="AL">Albania</option>
|
||||||
|
<option value="XK">Kosovo</option>
|
||||||
|
<option value="BG">Bulgaria</option>
|
||||||
|
<option value="RO">Romania</option>
|
||||||
|
<option value="HU">Hungary</option>
|
||||||
|
<option value="DE">Germany</option>
|
||||||
|
<option value="AT">Austria</option>
|
||||||
|
<option value="CH">Switzerland</option>
|
||||||
|
<option value="FR">France</option>
|
||||||
|
<option value="GB">United Kingdom</option>
|
||||||
|
<option value="US">United States</option>
|
||||||
|
<option value="CA">Canada</option>
|
||||||
|
<option value="AU">Australia</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<label className="block text-sm font-medium mb-1">{t("streetAddress")}</label>
|
<label className="block text-sm font-medium mb-1">{t("streetAddress")}</label>
|
||||||
<input
|
<input
|
||||||
@@ -289,16 +601,6 @@ export default function CheckoutPage() {
|
|||||||
className="w-full border border-border px-4 py-2 rounded"
|
className="w-full border border-border px-4 py-2 rounded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-sm font-medium mb-1">{t("phone")}</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
required
|
|
||||||
value={shippingAddress.phone}
|
|
||||||
onChange={(e) => handleShippingChange("phone", e.target.value)}
|
|
||||||
className="w-full border border-border px-4 py-2 rounded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -314,9 +616,53 @@ export default function CheckoutPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Shipping Method Selection */}
|
||||||
|
<div className="border-b border-border pb-6">
|
||||||
|
<h2 className="text-xl font-serif mb-4">{t("shippingMethod")}</h2>
|
||||||
|
{isLoadingShipping ? (
|
||||||
|
<div className="flex items-center gap-2 text-foreground-muted">
|
||||||
|
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>{t("loadingShippingMethods")}</span>
|
||||||
|
</div>
|
||||||
|
) : shippingMethods.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{shippingMethods.map((method) => (
|
||||||
|
<label
|
||||||
|
key={method.id}
|
||||||
|
className={`flex items-center justify-between p-4 border rounded cursor-pointer transition-colors ${
|
||||||
|
selectedShippingMethod === method.id
|
||||||
|
? "border-foreground bg-background-ice"
|
||||||
|
: "border-border hover:border-foreground/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="shippingMethod"
|
||||||
|
value={method.id}
|
||||||
|
checked={selectedShippingMethod === method.id}
|
||||||
|
onChange={(e) => setSelectedShippingMethod(e.target.value)}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="font-medium">{method.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-foreground-muted">
|
||||||
|
{formatPrice(method.price.amount)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-foreground-muted">{t("enterAddressForShipping")}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || lines.length === 0}
|
disabled={isLoading || lines.length === 0 || !selectedShippingMethod}
|
||||||
className="w-full py-4 bg-foreground text-white font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
|
className="w-full py-4 bg-foreground text-white font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isLoading ? t("processing") : t("completeOrder", { total: formatPrice(total) })}
|
{isLoading ? t("processing") : t("completeOrder", { total: formatPrice(total) })}
|
||||||
@@ -363,6 +709,12 @@ export default function CheckoutPage() {
|
|||||||
<span className="text-foreground-muted">{t("subtotal")}</span>
|
<span className="text-foreground-muted">{t("subtotal")}</span>
|
||||||
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
|
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{selectedShippingMethod && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-foreground-muted">{t("shipping")}</span>
|
||||||
|
<span>{formatPrice(shippingMethods.find(m => m.id === selectedShippingMethod)?.price.amount || 0)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex justify-between font-medium text-lg pt-2 border-t border-border">
|
<div className="flex justify-between font-medium text-lg pt-2 border-t border-border">
|
||||||
<span>{t("total")}</span>
|
<span>{t("total")}</span>
|
||||||
<span>{formatPrice(total)}</span>
|
<span>{formatPrice(total)}</span>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Metadata } from "next";
|
|||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getMessages, setRequestLocale } from "next-intl/server";
|
import { getMessages, setRequestLocale } from "next-intl/server";
|
||||||
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
|
import { SUPPORTED_LOCALES, DEFAULT_LOCALE, isValidLocale } from "@/lib/i18n/locales";
|
||||||
|
import { OpenPanelComponent } from "@openpanel/nextjs";
|
||||||
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||||
|
|
||||||
@@ -44,8 +45,17 @@ export default async function LocaleLayout({
|
|||||||
const messages = await getMessages();
|
const messages = await getMessages();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NextIntlClientProvider messages={messages}>
|
<>
|
||||||
{children}
|
<OpenPanelComponent
|
||||||
</NextIntlClientProvider>
|
clientId={process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || ""}
|
||||||
|
trackScreenViews={true}
|
||||||
|
trackOutgoingLinks={true}
|
||||||
|
apiUrl="https://op.nodecrew.me/api"
|
||||||
|
scriptUrl="https://op.nodecrew.me/op1.js"
|
||||||
|
/>
|
||||||
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
{children}
|
||||||
|
</NextIntlClientProvider>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getProducts } from "@/lib/saleor";
|
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
import Header from "@/components/layout/Header";
|
import Header from "@/components/layout/Header";
|
||||||
import Footer from "@/components/layout/Footer";
|
import Footer from "@/components/layout/Footer";
|
||||||
@@ -40,7 +40,8 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
|
|||||||
console.log("Failed to fetch products during build");
|
console.log("Failed to fetch products during build");
|
||||||
}
|
}
|
||||||
|
|
||||||
const featuredProducts = products?.slice(0, 4) || [];
|
const filteredProducts = filterOutBundles(products);
|
||||||
|
const featuredProducts = filteredProducts.slice(0, 4);
|
||||||
const hasProducts = featuredProducts.length > 0;
|
const hasProducts = featuredProducts.length > 0;
|
||||||
|
|
||||||
const basePath = `/${validLocale}`;
|
const basePath = `/${validLocale}`;
|
||||||
@@ -206,7 +207,7 @@ export default async function Homepage({ params }: { params: Promise<{ locale: s
|
|||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
placeholder={t("emailPlaceholder")}
|
placeholder={t("emailPlaceholder")}
|
||||||
className="flex-1 min-w-0 px-5 !h-14 bg-white/10 border border-white/20 border-b-0 sm:border-b border-r-0 sm:border-r border-white/20 text-white placeholder:text-white/50 focus:border-white focus:outline-none transition-colors text-base text-center sm:text-left rounded-t sm:rounded-l sm:rounded-tr-none"
|
className="flex-1 min-w-0 px-5 !h-16 bg-white/10 border border-white/20 border-b-0 sm:border-b border-r-0 sm:border-r border-white/20 text-white placeholder:text-white/50 focus:border-white focus:outline-none transition-colors text-base text-center sm:text-left rounded-t sm:rounded-l sm:rounded-tr-none"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getProductBySlug, getProducts, getLocalizedProduct } from "@/lib/saleor";
|
import { getProductBySlug, getProducts, getLocalizedProduct, getBundleProducts, filterOutBundles } from "@/lib/saleor";
|
||||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
import Header from "@/components/layout/Header";
|
import Header from "@/components/layout/Header";
|
||||||
import Footer from "@/components/layout/Footer";
|
import Footer from "@/components/layout/Footer";
|
||||||
@@ -20,7 +20,8 @@ export async function generateStaticParams() {
|
|||||||
try {
|
try {
|
||||||
const saleorLocale = locale === "sr" ? "SR" : "EN";
|
const saleorLocale = locale === "sr" ? "SR" : "EN";
|
||||||
const products = await getProducts(saleorLocale, 100);
|
const products = await getProducts(saleorLocale, 100);
|
||||||
products.forEach((product: Product) => {
|
const filteredProducts = filterOutBundles(products);
|
||||||
|
filteredProducts.forEach((product: Product) => {
|
||||||
params.push({ locale, slug: product.slug });
|
params.push({ locale, slug: product.slug });
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -86,13 +87,27 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let relatedProducts: Product[] = [];
|
let relatedProducts: Product[] = [];
|
||||||
|
let bundleProducts: Product[] = [];
|
||||||
try {
|
try {
|
||||||
const allProducts = await getProducts(productLocale, 8);
|
const allProducts = await getProducts(saleorLocale, 50);
|
||||||
relatedProducts = allProducts
|
relatedProducts = filterOutBundles(allProducts)
|
||||||
.filter((p: Product) => p.id !== product.id)
|
.filter((p: Product) => p.id !== product.id)
|
||||||
.slice(0, 4);
|
.slice(0, 4);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const allBundleProducts = await getBundleProducts(saleorLocale, 50);
|
||||||
|
bundleProducts = allBundleProducts.filter((p) => {
|
||||||
|
const bundleAttr = p.attributes?.find(
|
||||||
|
(attr) => attr.attribute.slug === "bundle-items"
|
||||||
|
);
|
||||||
|
if (!bundleAttr || bundleAttr.values.length === 0) return false;
|
||||||
|
return bundleAttr.values.some((val) => {
|
||||||
|
return val.name === product.name || p.name.includes(product.name.split(" - ")[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header locale={locale} />
|
<Header locale={locale} />
|
||||||
@@ -100,6 +115,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||||||
<ProductDetail
|
<ProductDetail
|
||||||
product={product}
|
product={product}
|
||||||
relatedProducts={relatedProducts}
|
relatedProducts={relatedProducts}
|
||||||
|
bundleProducts={bundleProducts}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getProducts } from "@/lib/saleor";
|
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||||
import { getTranslations, setRequestLocale } from "next-intl/server";
|
import { getTranslations, setRequestLocale } from "next-intl/server";
|
||||||
import Header from "@/components/layout/Header";
|
import Header from "@/components/layout/Header";
|
||||||
import Footer from "@/components/layout/Footer";
|
import Footer from "@/components/layout/Footer";
|
||||||
@@ -27,7 +27,9 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
setRequestLocale(validLocale);
|
setRequestLocale(validLocale);
|
||||||
const t = await getTranslations("Products");
|
const t = await getTranslations("Products");
|
||||||
const saleorLocale = getSaleorLocale(validLocale as Locale);
|
const saleorLocale = getSaleorLocale(validLocale as Locale);
|
||||||
const products = await getProducts(saleorLocale);
|
const allProducts = await getProducts(saleorLocale);
|
||||||
|
|
||||||
|
const products = filterOutBundles(allProducts);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -86,7 +88,7 @@ export default async function ProductsPage({ params }: ProductsPageProps) {
|
|||||||
key={product.id}
|
key={product.id}
|
||||||
product={product}
|
product={product}
|
||||||
index={index}
|
index={index}
|
||||||
locale={productLocale}
|
locale={validLocale}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
5
src/app/api/op/[...path]/route.ts
Normal file
5
src/app/api/op/[...path]/route.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createRouteHandler } from "@openpanel/nextjs/server";
|
||||||
|
|
||||||
|
export const { GET, POST } = createRouteHandler({
|
||||||
|
apiUrl: "https://op.nodecrew.me/api",
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { MetadataRoute } from "next";
|
import { MetadataRoute } from "next";
|
||||||
import { getProducts } from "@/lib/saleor";
|
import { getProducts, filterOutBundles } from "@/lib/saleor";
|
||||||
import { SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/locales";
|
import { SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/locales";
|
||||||
|
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
||||||
@@ -80,9 +80,11 @@ export default async function sitemap(): Promise<SitemapEntry[]> {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const filteredProducts = filterOutBundles(products);
|
||||||
|
|
||||||
const productUrls: SitemapEntry[] = [];
|
const productUrls: SitemapEntry[] = [];
|
||||||
|
|
||||||
for (const product of products) {
|
for (const product of filteredProducts) {
|
||||||
const hreflangs: Record<string, string> = {};
|
const hreflangs: Record<string, string> = {};
|
||||||
for (const locale of SUPPORTED_LOCALES) {
|
for (const locale of SUPPORTED_LOCALES) {
|
||||||
const path = locale === "sr" ? `/products/${product.slug}` : `/${locale}/products/${product.slug}`;
|
const path = locale === "sr" ? `/products/${product.slug}` : `/${locale}/products/${product.slug}`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -30,14 +30,16 @@ export default function CartDrawer() {
|
|||||||
const lines = getLines();
|
const lines = getLines();
|
||||||
const total = getTotal();
|
const total = getTotal();
|
||||||
const lineCount = getLineCount();
|
const lineCount = getLineCount();
|
||||||
const [initialized, setInitialized] = useState(false);
|
const initializedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized) {
|
if (!initializedRef.current && locale) {
|
||||||
|
// Set language code before initializing checkout
|
||||||
|
useSaleorCheckoutStore.getState().setLanguageCode(locale);
|
||||||
initCheckout();
|
initCheckout();
|
||||||
setInitialized(true);
|
initializedRef.current = true;
|
||||||
}
|
}
|
||||||
}, [initialized]);
|
}, [locale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ interface NewHeroProps {
|
|||||||
|
|
||||||
export default function NewHero({ featuredProduct }: NewHeroProps) {
|
export default function NewHero({ featuredProduct }: NewHeroProps) {
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const { addLine, openCart } = useSaleorCheckoutStore();
|
const { addLine, openCart, setLanguageCode } = useSaleorCheckoutStore();
|
||||||
|
|
||||||
const handleAddToCart = async () => {
|
const handleAddToCart = async () => {
|
||||||
|
// Set language code before adding to cart
|
||||||
|
if (locale) {
|
||||||
|
setLanguageCode(locale);
|
||||||
|
}
|
||||||
const variant = featuredProduct?.variants?.[0];
|
const variant = featuredProduct?.variants?.[0];
|
||||||
if (variant?.id) {
|
if (variant?.id) {
|
||||||
await addLine(variant.id, 1);
|
await addLine(variant.id, 1);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function NewsletterSection() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder={t("emailPlaceholder")}
|
placeholder={t("emailPlaceholder")}
|
||||||
required
|
required
|
||||||
className="flex-1 px-4 py-4 h-12 border border-[#1A1A1A]/10 rounded-[4px] text-sm focus:outline-none focus:border-[#1A1A1A]/30 transition-colors"
|
className="flex-1 px-4 py-4 h-14 border border-[#1A1A1A]/10 rounded-[4px] text-base focus:outline-none focus:border-[#1A1A1A]/30 transition-colors"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from "next/link";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations, useLocale } from "next-intl";
|
||||||
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
|
||||||
import { User, ShoppingBag, Menu, X, Globe } from "lucide-react";
|
import { User, ShoppingBag, Menu, X, Globe } from "lucide-react";
|
||||||
import CartDrawer from "@/components/cart/CartDrawer";
|
import CartDrawer from "@/components/cart/CartDrawer";
|
||||||
@@ -16,14 +16,15 @@ interface HeaderProps {
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({ locale = "sr" }: HeaderProps) {
|
export default function Header({ locale: propLocale = "sr" }: HeaderProps) {
|
||||||
const t = useTranslations("Header");
|
const t = useTranslations("Header");
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [scrolled, setScrolled] = useState(false);
|
const [scrolled, setScrolled] = useState(false);
|
||||||
const [langDropdownOpen, setLangDropdownOpen] = useState(false);
|
const [langDropdownOpen, setLangDropdownOpen] = useState(false);
|
||||||
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
|
const { getLineCount, toggleCart, initCheckout, setLanguageCode } = useSaleorCheckoutStore();
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
const itemCount = getLineCount();
|
const itemCount = getLineCount();
|
||||||
const currentLocale = isValidLocale(locale) ? LOCALE_CONFIG[locale] : LOCALE_CONFIG.sr;
|
const currentLocale = isValidLocale(locale) ? LOCALE_CONFIG[locale] : LOCALE_CONFIG.sr;
|
||||||
@@ -54,9 +55,14 @@ export default function Header({ locale = "sr" }: HeaderProps) {
|
|||||||
setLangDropdownOpen(false);
|
setLangDropdownOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set language code first, then initialize checkout
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initCheckout();
|
if (locale) {
|
||||||
}, [initCheckout]);
|
setLanguageCode(locale);
|
||||||
|
// Initialize checkout after language code is set
|
||||||
|
initCheckout();
|
||||||
|
}
|
||||||
|
}, [locale, setLanguageCode, initCheckout]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
|
|||||||
163
src/components/product/BundleSelector.tsx
Normal file
163
src/components/product/BundleSelector.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import type { Product } from "@/types/saleor";
|
||||||
|
import { getProductPrice, formatPrice } from "@/lib/saleor";
|
||||||
|
|
||||||
|
interface BundleSelectorProps {
|
||||||
|
baseProduct: Product;
|
||||||
|
bundleProducts: Product[];
|
||||||
|
selectedVariantId: string | null;
|
||||||
|
onSelectVariant: (variantId: string, quantity: number, price: number) => void;
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BundleOption {
|
||||||
|
product: Product;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
pricePerUnit: number;
|
||||||
|
savings: number;
|
||||||
|
isBase: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BundleSelector({
|
||||||
|
baseProduct,
|
||||||
|
bundleProducts,
|
||||||
|
selectedVariantId,
|
||||||
|
onSelectVariant,
|
||||||
|
locale,
|
||||||
|
}: BundleSelectorProps) {
|
||||||
|
const t = useTranslations("Bundle");
|
||||||
|
|
||||||
|
if (bundleProducts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseVariant = baseProduct.variants?.[0];
|
||||||
|
const basePrice = baseVariant?.pricing?.price?.gross?.amount || 0;
|
||||||
|
|
||||||
|
const options: BundleOption[] = [];
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
product: baseProduct,
|
||||||
|
quantity: 1,
|
||||||
|
price: basePrice,
|
||||||
|
pricePerUnit: basePrice,
|
||||||
|
savings: 0,
|
||||||
|
isBase: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
bundleProducts.forEach((bundle) => {
|
||||||
|
const variant = bundle.variants?.[0];
|
||||||
|
if (!variant?.pricing?.price?.gross?.amount) return;
|
||||||
|
|
||||||
|
const price = variant.pricing.price.gross.amount;
|
||||||
|
const quantityMatch = bundle.name.match(/(\d+)x/i);
|
||||||
|
const quantity = quantityMatch ? parseInt(quantityMatch[1], 10) : 1;
|
||||||
|
const pricePerUnit = price / quantity;
|
||||||
|
const savings = (basePrice * quantity) - price;
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
product: bundle,
|
||||||
|
quantity,
|
||||||
|
price,
|
||||||
|
pricePerUnit,
|
||||||
|
savings,
|
||||||
|
isBase: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
options.sort((a, b) => a.quantity - b.quantity);
|
||||||
|
|
||||||
|
const formatPriceWithLocale = (amount: number, currency: string = "RSD") => {
|
||||||
|
const localeMap: Record<string, string> = {
|
||||||
|
sr: "sr-RS",
|
||||||
|
en: "en-US",
|
||||||
|
de: "de-DE",
|
||||||
|
fr: "fr-FR",
|
||||||
|
};
|
||||||
|
const numLocale = localeMap[locale] || "sr-RS";
|
||||||
|
return new Intl.NumberFormat(numLocale, {
|
||||||
|
style: "currency",
|
||||||
|
currency,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm uppercase tracking-[0.1em] font-medium">
|
||||||
|
{t("selectBundle")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{options.map((option) => {
|
||||||
|
const variantId = option.isBase
|
||||||
|
? baseVariant?.id
|
||||||
|
: option.product.variants?.[0]?.id;
|
||||||
|
const isSelected = selectedVariantId === variantId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={option.product.id}
|
||||||
|
onClick={() => variantId && onSelectVariant(variantId, option.quantity, option.price)}
|
||||||
|
className={`w-full p-4 border-2 transition-all text-left ${
|
||||||
|
isSelected
|
||||||
|
? "border-black bg-black text-white"
|
||||||
|
: "border-[#e5e5e5] hover:border-[#999999]"
|
||||||
|
}`}
|
||||||
|
whileHover={{ scale: 1.01 }}
|
||||||
|
whileTap={{ scale: 0.99 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||||
|
isSelected
|
||||||
|
? "border-white bg-white"
|
||||||
|
: "border-[#999999]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
className="w-2.5 h-2.5 rounded-full bg-black"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">
|
||||||
|
{option.isBase ? t("singleUnit") : t("xSet", { count: option.quantity })}
|
||||||
|
</span>
|
||||||
|
{!option.isBase && option.savings > 0 && (
|
||||||
|
<span className="ml-2 text-xs text-green-500">
|
||||||
|
{t("save", { amount: formatPriceWithLocale(option.savings) })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
<div className={`font-bold ${isSelected ? "text-white" : "text-black"}`}>
|
||||||
|
{formatPriceWithLocale(option.price)}
|
||||||
|
</div>
|
||||||
|
{!option.isBase && (
|
||||||
|
<div className={`text-xs ${isSelected ? "text-white/70" : "text-[#666666]"}`}>
|
||||||
|
{formatPriceWithLocale(option.pricePerUnit)} {t("perUnit")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,10 +19,13 @@ import TrustBadges from "@/components/home/TrustBadges";
|
|||||||
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
|
import BeforeAfterGallery from "@/components/home/BeforeAfterGallery";
|
||||||
import HowItWorks from "@/components/home/HowItWorks";
|
import HowItWorks from "@/components/home/HowItWorks";
|
||||||
import NewsletterSection from "@/components/home/NewsletterSection";
|
import NewsletterSection from "@/components/home/NewsletterSection";
|
||||||
|
import BundleSelector from "@/components/product/BundleSelector";
|
||||||
|
import { useAnalytics } from "@/lib/analytics";
|
||||||
|
|
||||||
interface ProductDetailProps {
|
interface ProductDetailProps {
|
||||||
product: Product;
|
product: Product;
|
||||||
relatedProducts: Product[];
|
relatedProducts: Product[];
|
||||||
|
bundleProducts?: Product[];
|
||||||
locale?: string;
|
locale?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,16 +91,34 @@ function StarRating({ rating = 5, count = 0 }: { rating?: number; count?: number
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductDetail({ product, relatedProducts, locale = "sr" }: ProductDetailProps) {
|
export default function ProductDetail({ product, relatedProducts, bundleProducts = [], locale = "sr" }: ProductDetailProps) {
|
||||||
const t = useTranslations("ProductDetail");
|
const t = useTranslations("ProductDetail");
|
||||||
const tProduct = useTranslations("Product");
|
const tProduct = useTranslations("Product");
|
||||||
const [selectedImage, setSelectedImage] = useState(0);
|
const [selectedImage, setSelectedImage] = useState(0);
|
||||||
const [quantity, setQuantity] = useState(1);
|
const [quantity, setQuantity] = useState(1);
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
const [urgencyIndex, setUrgencyIndex] = useState(0);
|
const [urgencyIndex, setUrgencyIndex] = useState(0);
|
||||||
const { addLine, openCart } = useSaleorCheckoutStore();
|
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
|
||||||
|
const { addLine, openCart, setLanguageCode } = useSaleorCheckoutStore();
|
||||||
|
const { trackProductView, trackAddToCart } = useAnalytics();
|
||||||
const validLocale = isValidLocale(locale) ? locale : "sr";
|
const validLocale = isValidLocale(locale) ? locale : "sr";
|
||||||
|
|
||||||
|
// Track product view on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const localized = getLocalizedProduct(product, locale);
|
||||||
|
const baseVariant = product.variants?.[0];
|
||||||
|
const price = baseVariant?.pricing?.price?.gross?.amount || 0;
|
||||||
|
const currency = baseVariant?.pricing?.price?.gross?.currency || "RSD";
|
||||||
|
|
||||||
|
trackProductView({
|
||||||
|
id: product.id,
|
||||||
|
name: localized.name,
|
||||||
|
price,
|
||||||
|
currency,
|
||||||
|
category: product.category?.name,
|
||||||
|
});
|
||||||
|
}, [product, locale]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setUrgencyIndex(prev => (prev + 1) % 3);
|
setUrgencyIndex(prev => (prev + 1) % 3);
|
||||||
@@ -112,28 +133,77 @@ export default function ProductDetail({ product, relatedProducts, locale = "sr"
|
|||||||
];
|
];
|
||||||
|
|
||||||
const localized = getLocalizedProduct(product, locale);
|
const localized = getLocalizedProduct(product, locale);
|
||||||
const variant = product.variants?.[0];
|
const baseVariant = product.variants?.[0];
|
||||||
|
const selectedVariantId = selectedBundleVariantId || baseVariant?.id;
|
||||||
|
|
||||||
|
const selectedVariant = selectedVariantId === baseVariant?.id
|
||||||
|
? baseVariant
|
||||||
|
: bundleProducts.find(p => p.variants?.[0]?.id === selectedVariantId)?.variants?.[0];
|
||||||
|
|
||||||
const images = product.media?.length > 0
|
const images = product.media?.length > 0
|
||||||
? product.media.filter(m => m.type === "IMAGE")
|
? product.media.filter(m => m.type === "IMAGE")
|
||||||
: [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" as const }];
|
: [{ id: "0", url: "/placeholder-product.jpg", alt: localized.name, type: "IMAGE" as const }];
|
||||||
|
|
||||||
const handleAddToCart = async () => {
|
const handleAddToCart = async () => {
|
||||||
if (!variant?.id) return;
|
if (!selectedVariantId) return;
|
||||||
|
|
||||||
|
// Set language code before adding to cart
|
||||||
|
if (validLocale) {
|
||||||
|
setLanguageCode(validLocale);
|
||||||
|
}
|
||||||
|
|
||||||
setIsAdding(true);
|
setIsAdding(true);
|
||||||
try {
|
try {
|
||||||
await addLine(variant.id, quantity);
|
await addLine(selectedVariantId, 1);
|
||||||
|
|
||||||
|
// Track add to cart
|
||||||
|
const localized = getLocalizedProduct(product, locale);
|
||||||
|
const baseVariant = product.variants?.[0];
|
||||||
|
const selectedVariant = selectedVariantId === baseVariant?.id
|
||||||
|
? baseVariant
|
||||||
|
: bundleProducts.find(p => p.variants?.[0]?.id === selectedVariantId)?.variants?.[0];
|
||||||
|
const price = selectedVariant?.pricing?.price?.gross?.amount || 0;
|
||||||
|
const currency = selectedVariant?.pricing?.price?.gross?.currency || "RSD";
|
||||||
|
|
||||||
|
trackAddToCart({
|
||||||
|
id: product.id,
|
||||||
|
name: localized.name,
|
||||||
|
price,
|
||||||
|
currency,
|
||||||
|
quantity: 1,
|
||||||
|
variant: selectedVariant?.name,
|
||||||
|
});
|
||||||
|
|
||||||
openCart();
|
openCart();
|
||||||
} finally {
|
} finally {
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isAvailable = variant?.quantityAvailable > 0;
|
const handleSelectVariant = (variantId: string, qty: number, price: number) => {
|
||||||
const price = getProductPrice(product);
|
setSelectedBundleVariantId(variantId);
|
||||||
const priceAmount = getProductPriceAmount(product);
|
setQuantity(qty);
|
||||||
const originalPrice = priceAmount > 0 ? formatPrice(Math.round(priceAmount * 1.30)) : null;
|
};
|
||||||
|
|
||||||
|
const isAvailable = (selectedVariant?.quantityAvailable ?? 0) > 0;
|
||||||
|
|
||||||
|
const selectedPrice = selectedVariant?.pricing?.price?.gross?.amount || 0;
|
||||||
|
const price = selectedPrice > 0
|
||||||
|
? new Intl.NumberFormat(validLocale === "en" ? "en-US" : validLocale === "de" ? "de-DE" : validLocale === "fr" ? "fr-FR" : "sr-RS", {
|
||||||
|
style: "currency",
|
||||||
|
currency: selectedVariant?.pricing?.price?.gross?.currency || "RSD",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(selectedPrice)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const priceAmount = selectedPrice;
|
||||||
|
const originalPrice = priceAmount > 0 ? new Intl.NumberFormat(validLocale === "en" ? "en-US" : validLocale === "de" ? "de-DE" : validLocale === "fr" ? "fr-FR" : "sr-RS", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "RSD",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(Math.round(priceAmount * 1.30)) : null;
|
||||||
|
|
||||||
const shortDescription = getTranslatedShortDescription(localized.description, validLocale);
|
const shortDescription = getTranslatedShortDescription(localized.description, validLocale);
|
||||||
|
|
||||||
@@ -292,52 +362,40 @@ export default function ProductDetail({ product, relatedProducts, locale = "sr"
|
|||||||
|
|
||||||
<div className="border-t border-[#e5e5e5] mb-8" />
|
<div className="border-t border-[#e5e5e5] mb-8" />
|
||||||
|
|
||||||
{product.variants && product.variants.length > 1 && (
|
{bundleProducts.length > 0 ? (
|
||||||
<div className="mb-8">
|
<BundleSelector
|
||||||
<div className="flex items-center justify-between mb-4">
|
baseProduct={product}
|
||||||
<span className="text-sm uppercase tracking-[0.1em] font-medium">
|
bundleProducts={bundleProducts}
|
||||||
{t("size")}
|
selectedVariantId={selectedBundleVariantId || baseVariant?.id || null}
|
||||||
</span>
|
onSelectVariant={handleSelectVariant}
|
||||||
|
locale={validLocale}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
product.variants && product.variants.length > 1 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<span className="text-sm uppercase tracking-[0.1em] font-medium">
|
||||||
|
{t("size")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{product.variants.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
className={`px-5 py-3 text-sm border-2 transition-colors ${
|
||||||
|
v.id === baseVariant?.id
|
||||||
|
? "border-black bg-black text-white"
|
||||||
|
: "border-[#e5e5e5] hover:border-[#999999]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{v.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
)
|
||||||
{product.variants.map((v) => (
|
|
||||||
<button
|
|
||||||
key={v.id}
|
|
||||||
className={`px-5 py-3 text-sm border-2 transition-colors ${
|
|
||||||
v.id === variant?.id
|
|
||||||
? "border-black bg-black text-white"
|
|
||||||
: "border-[#e5e5e5] hover:border-[#999999]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{v.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-4 mb-8">
|
|
||||||
<span className="text-sm uppercase tracking-[0.1em] font-medium w-16">
|
|
||||||
{t("qty")}
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center border-2 border-[#1a1a1a]">
|
|
||||||
<button
|
|
||||||
onClick={() => setQuantity(Math.max(1, quantity - 1))}
|
|
||||||
className="w-12 h-12 flex items-center justify-center hover:bg-[#f8f9fa] transition-colors"
|
|
||||||
disabled={quantity <= 1}
|
|
||||||
>
|
|
||||||
<Minus className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<span className="w-14 text-center text-base font-medium">{quantity}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setQuantity(quantity + 1)}
|
|
||||||
className="w-12 h-12 flex items-center justify-center hover:bg-[#f8f9fa] transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isAvailable ? (
|
{isAvailable ? (
|
||||||
<button
|
<button
|
||||||
onClick={handleAddToCart}
|
onClick={handleAddToCart}
|
||||||
@@ -425,9 +483,9 @@ export default function ProductDetail({ product, relatedProducts, locale = "sr"
|
|||||||
</ExpandableSection>
|
</ExpandableSection>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{variant?.sku && (
|
{selectedVariant?.sku && (
|
||||||
<p className="text-xs text-[#999999] mt-8">
|
<p className="text-xs text-[#999999] mt-8">
|
||||||
SKU: {variant.sku}
|
SKU: {selectedVariant.sku}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -312,6 +312,13 @@
|
|||||||
"urgency2": "In den Warenkörben von 2,5K Menschen - kaufen Sie, bevor es weg ist!",
|
"urgency2": "In den Warenkörben von 2,5K Menschen - kaufen Sie, bevor es weg ist!",
|
||||||
"urgency3": "7.562 Personen haben sich dieses Produkt in den letzten 24 Stunden angesehen!"
|
"urgency3": "7.562 Personen haben sich dieses Produkt in den letzten 24 Stunden angesehen!"
|
||||||
},
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Paket wählen",
|
||||||
|
"singleUnit": "1 Stück",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Spare {amount}",
|
||||||
|
"perUnit": "pro Stück"
|
||||||
|
},
|
||||||
"Newsletter": {
|
"Newsletter": {
|
||||||
"stayConnected": "Bleiben Sie verbunden",
|
"stayConnected": "Bleiben Sie verbunden",
|
||||||
"joinCommunity": "Werden Sie Teil unserer Gemeinschaft",
|
"joinCommunity": "Werden Sie Teil unserer Gemeinschaft",
|
||||||
@@ -333,7 +340,13 @@
|
|||||||
},
|
},
|
||||||
"Checkout": {
|
"Checkout": {
|
||||||
"checkout": "Kasse",
|
"checkout": "Kasse",
|
||||||
|
"contactInfo": "Kontaktinformationen",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"emailRequired": "Erforderlich für Bestellbestätigung",
|
||||||
|
"phoneRequired": "Erforderlich für Lieferkoordination",
|
||||||
"shippingAddress": "Lieferadresse",
|
"shippingAddress": "Lieferadresse",
|
||||||
|
"shippingMethod": "Versandart",
|
||||||
|
"country": "Land",
|
||||||
"firstName": "Vorname",
|
"firstName": "Vorname",
|
||||||
"lastName": "Nachname",
|
"lastName": "Nachname",
|
||||||
"streetAddress": "Straße und Nummer",
|
"streetAddress": "Straße und Nummer",
|
||||||
@@ -357,6 +370,13 @@
|
|||||||
"yourCartEmpty": "Ihr Warenkorb ist leer",
|
"yourCartEmpty": "Ihr Warenkorb ist leer",
|
||||||
"continueShopping": "Weiter einkaufen",
|
"continueShopping": "Weiter einkaufen",
|
||||||
"errorNoCheckout": "Keine aktive Kasse. Bitte versuchen Sie es erneut.",
|
"errorNoCheckout": "Keine aktive Kasse. Bitte versuchen Sie es erneut.",
|
||||||
|
"errorEmailRequired": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
|
||||||
|
"errorFieldsRequired": "Bitte füllen Sie alle erforderlichen Felder aus.",
|
||||||
|
"errorNoShippingMethods": "Keine Versandmethoden für diese Adresse verfügbar. Bitte überprüfen Sie Ihre Adresse oder kontaktieren Sie den Support.",
|
||||||
|
"errorSelectShipping": "Bitte wählen Sie eine Versandmethode.",
|
||||||
|
"errorPhoneRequired": "Bitte geben Sie eine gültige Telefonnummer ein.",
|
||||||
|
"loadingShippingMethods": "Versandoptionen werden geladen...",
|
||||||
|
"enterAddressForShipping": "Geben Sie Ihre Adresse ein, um Versandoptionen zu sehen.",
|
||||||
"errorOccurred": "Ein Fehler ist during des Checkouts aufgetreten.",
|
"errorOccurred": "Ein Fehler ist during des Checkouts aufgetreten.",
|
||||||
"errorCreatingOrder": "Bestellung konnte nicht erstellt werden.",
|
"errorCreatingOrder": "Bestellung konnte nicht erstellt werden.",
|
||||||
"orderConfirmed": "Bestellung bestätigt!",
|
"orderConfirmed": "Bestellung bestätigt!",
|
||||||
|
|||||||
@@ -341,6 +341,13 @@
|
|||||||
"urgency2": "In the carts of 2.5K people - buy before its gone!",
|
"urgency2": "In the carts of 2.5K people - buy before its gone!",
|
||||||
"urgency3": "7,562 people viewed this product in the last 24 hours!"
|
"urgency3": "7,562 people viewed this product in the last 24 hours!"
|
||||||
},
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Select Package",
|
||||||
|
"singleUnit": "1 Unit",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Save {amount}",
|
||||||
|
"perUnit": "per unit"
|
||||||
|
},
|
||||||
"Newsletter": {
|
"Newsletter": {
|
||||||
"stayConnected": "Stay Connected",
|
"stayConnected": "Stay Connected",
|
||||||
"joinCommunity": "Join Our Community",
|
"joinCommunity": "Join Our Community",
|
||||||
@@ -379,7 +386,13 @@
|
|||||||
},
|
},
|
||||||
"Checkout": {
|
"Checkout": {
|
||||||
"checkout": "Checkout",
|
"checkout": "Checkout",
|
||||||
|
"contactInfo": "Contact Information",
|
||||||
|
"email": "Email",
|
||||||
|
"emailRequired": "Required for order confirmation",
|
||||||
|
"phoneRequired": "Required for delivery coordination",
|
||||||
"shippingAddress": "Shipping Address",
|
"shippingAddress": "Shipping Address",
|
||||||
|
"shippingMethod": "Shipping Method",
|
||||||
|
"country": "Country",
|
||||||
"firstName": "First Name",
|
"firstName": "First Name",
|
||||||
"lastName": "Last Name",
|
"lastName": "Last Name",
|
||||||
"streetAddress": "Street Address",
|
"streetAddress": "Street Address",
|
||||||
@@ -403,8 +416,16 @@
|
|||||||
"yourCartEmpty": "Your cart is empty",
|
"yourCartEmpty": "Your cart is empty",
|
||||||
"continueShopping": "Continue Shopping",
|
"continueShopping": "Continue Shopping",
|
||||||
"errorNoCheckout": "No active checkout. Please try again.",
|
"errorNoCheckout": "No active checkout. Please try again.",
|
||||||
|
"errorEmailRequired": "Please enter a valid email address.",
|
||||||
|
"errorFieldsRequired": "Please fill in all required fields.",
|
||||||
|
"errorNoShippingMethods": "No shipping methods available for this address. Please check your address or contact support.",
|
||||||
|
"errorSelectShipping": "Please select a shipping method.",
|
||||||
|
"errorPhoneRequired": "Please enter a valid phone number.",
|
||||||
|
"loadingShippingMethods": "Loading shipping options...",
|
||||||
|
"enterAddressForShipping": "Enter your address to see shipping options.",
|
||||||
"errorOccurred": "An error occurred during checkout.",
|
"errorOccurred": "An error occurred during checkout.",
|
||||||
"errorCreatingOrder": "Failed to create order.",
|
"errorCreatingOrder": "Failed to create order.",
|
||||||
|
"continueToShipping": "Continue to Shipping",
|
||||||
"orderConfirmed": "Order Confirmed!",
|
"orderConfirmed": "Order Confirmed!",
|
||||||
"thankYou": "Thank you for your purchase.",
|
"thankYou": "Thank you for your purchase.",
|
||||||
"orderNumber": "Order Number",
|
"orderNumber": "Order Number",
|
||||||
|
|||||||
@@ -312,6 +312,13 @@
|
|||||||
"urgency2": "Dans les paniers de 2,5K personnes - achetez avant qu'il ne disparaisse!",
|
"urgency2": "Dans les paniers de 2,5K personnes - achetez avant qu'il ne disparaisse!",
|
||||||
"urgency3": "7 562 personnes ont vu ce produit ces dernières 24 heures!"
|
"urgency3": "7 562 personnes ont vu ce produit ces dernières 24 heures!"
|
||||||
},
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Choisir le Pack",
|
||||||
|
"singleUnit": "1 Unité",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Économisez {amount}",
|
||||||
|
"perUnit": "par unité"
|
||||||
|
},
|
||||||
"Newsletter": {
|
"Newsletter": {
|
||||||
"stayConnected": "Restez Connectés",
|
"stayConnected": "Restez Connectés",
|
||||||
"joinCommunity": "Rejoignez Notre Communauté",
|
"joinCommunity": "Rejoignez Notre Communauté",
|
||||||
@@ -333,7 +340,13 @@
|
|||||||
},
|
},
|
||||||
"Checkout": {
|
"Checkout": {
|
||||||
"checkout": "Commande",
|
"checkout": "Commande",
|
||||||
|
"contactInfo": "Coordonnées",
|
||||||
|
"email": "E-mail",
|
||||||
|
"emailRequired": "Requis pour la confirmation de commande",
|
||||||
|
"phoneRequired": "Requis pour la coordination de livraison",
|
||||||
"shippingAddress": "Adresse de Livraison",
|
"shippingAddress": "Adresse de Livraison",
|
||||||
|
"shippingMethod": "Méthode de livraison",
|
||||||
|
"country": "Pays",
|
||||||
"firstName": "Prénom",
|
"firstName": "Prénom",
|
||||||
"lastName": "Nom",
|
"lastName": "Nom",
|
||||||
"streetAddress": "Rue et Numéro",
|
"streetAddress": "Rue et Numéro",
|
||||||
@@ -357,6 +370,13 @@
|
|||||||
"yourCartEmpty": "Votre panier est vide",
|
"yourCartEmpty": "Votre panier est vide",
|
||||||
"continueShopping": "Continuer les Achats",
|
"continueShopping": "Continuer les Achats",
|
||||||
"errorNoCheckout": "Pas de paiement actif. Veuillez réessayer.",
|
"errorNoCheckout": "Pas de paiement actif. Veuillez réessayer.",
|
||||||
|
"errorEmailRequired": "Veuillez entrer une adresse e-mail valide.",
|
||||||
|
"errorFieldsRequired": "Veuillez remplir tous les champs obligatoires.",
|
||||||
|
"errorNoShippingMethods": "Aucune méthode de livraison disponible pour cette adresse. Veuillez vérifier votre adresse ou contacter le support.",
|
||||||
|
"errorSelectShipping": "Veuillez sélectionner une méthode de livraison.",
|
||||||
|
"errorPhoneRequired": "Veuillez entrer un numéro de téléphone valide.",
|
||||||
|
"loadingShippingMethods": "Chargement des options de livraison...",
|
||||||
|
"enterAddressForShipping": "Entrez votre adresse pour voir les options de livraison.",
|
||||||
"errorOccurred": "Une erreur s'est produite lors du paiement.",
|
"errorOccurred": "Une erreur s'est produite lors du paiement.",
|
||||||
"errorCreatingOrder": "Échec de la création de la commande.",
|
"errorCreatingOrder": "Échec de la création de la commande.",
|
||||||
"orderConfirmed": "Commande Confirmée!",
|
"orderConfirmed": "Commande Confirmée!",
|
||||||
|
|||||||
@@ -341,6 +341,13 @@
|
|||||||
"urgency2": "U korpama 2.5K ljudi - kupi pre nego što nestane!",
|
"urgency2": "U korpama 2.5K ljudi - kupi pre nego što nestane!",
|
||||||
"urgency3": "7.562 osobe su pogledale ovaj proizvod u poslednja 24 sata!"
|
"urgency3": "7.562 osobe su pogledale ovaj proizvod u poslednja 24 sata!"
|
||||||
},
|
},
|
||||||
|
"Bundle": {
|
||||||
|
"selectBundle": "Izaberi pakovanje",
|
||||||
|
"singleUnit": "1 komad",
|
||||||
|
"xSet": "{count}x Set",
|
||||||
|
"save": "Štedi {amount}",
|
||||||
|
"perUnit": "po komadu"
|
||||||
|
},
|
||||||
"Newsletter": {
|
"Newsletter": {
|
||||||
"stayConnected": "Ostanite povezani",
|
"stayConnected": "Ostanite povezani",
|
||||||
"joinCommunity": "Pridružite se našoj zajednici",
|
"joinCommunity": "Pridružite se našoj zajednici",
|
||||||
@@ -379,7 +386,13 @@
|
|||||||
},
|
},
|
||||||
"Checkout": {
|
"Checkout": {
|
||||||
"checkout": "Kupovina",
|
"checkout": "Kupovina",
|
||||||
|
"contactInfo": "Kontakt informacije",
|
||||||
|
"email": "Email",
|
||||||
|
"emailRequired": "Potrebno za potvrdu narudžbine",
|
||||||
|
"phoneRequired": "Potrebno za koordinaciju dostave",
|
||||||
"shippingAddress": "Adresa za dostavu",
|
"shippingAddress": "Adresa za dostavu",
|
||||||
|
"shippingMethod": "Način dostave",
|
||||||
|
"country": "Država",
|
||||||
"firstName": "Ime",
|
"firstName": "Ime",
|
||||||
"lastName": "Prezime",
|
"lastName": "Prezime",
|
||||||
"streetAddress": "Ulica i broj",
|
"streetAddress": "Ulica i broj",
|
||||||
@@ -403,6 +416,13 @@
|
|||||||
"yourCartEmpty": "Vaša korpa je prazna",
|
"yourCartEmpty": "Vaša korpa je prazna",
|
||||||
"continueShopping": "Nastavi kupovinu",
|
"continueShopping": "Nastavi kupovinu",
|
||||||
"errorNoCheckout": "Nema aktivne korpe. Molimo pokušajte ponovo.",
|
"errorNoCheckout": "Nema aktivne korpe. Molimo pokušajte ponovo.",
|
||||||
|
"errorEmailRequired": "Molimo unesite validnu email adresu.",
|
||||||
|
"errorFieldsRequired": "Molimo popunite sva obavezna polja.",
|
||||||
|
"errorNoShippingMethods": "Nema dostupnih načina dostave za ovu adresu. Molimo proverite adresu ili kontaktirajte podršku.",
|
||||||
|
"errorSelectShipping": "Molimo izaberite način dostave.",
|
||||||
|
"errorPhoneRequired": "Molimo unesite validan broj telefona.",
|
||||||
|
"loadingShippingMethods": "Učitavanje opcija dostave...",
|
||||||
|
"enterAddressForShipping": "Unesite adresu da vidite opcije dostave.",
|
||||||
"errorOccurred": "Došlo je do greške prilikom kupovine.",
|
"errorOccurred": "Došlo je do greške prilikom kupovine.",
|
||||||
"errorCreatingOrder": "Neuspešno kreiranje narudžbine.",
|
"errorCreatingOrder": "Neuspešno kreiranje narudžbine.",
|
||||||
"orderConfirmed": "Narudžbina potvrđena!",
|
"orderConfirmed": "Narudžbina potvrđena!",
|
||||||
|
|||||||
154
src/lib/analytics.ts
Normal file
154
src/lib/analytics.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useOpenPanel } from "@openpanel/nextjs";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
export function useAnalytics() {
|
||||||
|
const op = useOpenPanel();
|
||||||
|
|
||||||
|
// Page views are tracked automatically by OpenPanelComponent
|
||||||
|
// but we can track specific events manually
|
||||||
|
|
||||||
|
const trackProductView = useCallback((product: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
category?: string;
|
||||||
|
}) => {
|
||||||
|
op.track("product_viewed", {
|
||||||
|
product_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
price: product.price,
|
||||||
|
currency: product.currency,
|
||||||
|
category: product.category,
|
||||||
|
});
|
||||||
|
}, [op]);
|
||||||
|
|
||||||
|
const trackAddToCart = useCallback((product: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
currency: string;
|
||||||
|
quantity: number;
|
||||||
|
variant?: string;
|
||||||
|
}) => {
|
||||||
|
op.track("add_to_cart", {
|
||||||
|
product_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
price: product.price,
|
||||||
|
currency: product.currency,
|
||||||
|
quantity: product.quantity,
|
||||||
|
variant: product.variant,
|
||||||
|
});
|
||||||
|
}, [op]);
|
||||||
|
|
||||||
|
const trackRemoveFromCart = useCallback((product: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
}) => {
|
||||||
|
op.track("remove_from_cart", {
|
||||||
|
product_id: product.id,
|
||||||
|
product_name: product.name,
|
||||||
|
quantity: product.quantity,
|
||||||
|
});
|
||||||
|
}, [op]);
|
||||||
|
|
||||||
|
const trackCheckoutStarted = useCallback((cart: {
|
||||||
|
total: number;
|
||||||
|
currency: string;
|
||||||
|
item_count: number;
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
}>;
|
||||||
|
}) => {
|
||||||
|
op.track("checkout_started", {
|
||||||
|
cart_total: cart.total,
|
||||||
|
currency: cart.currency,
|
||||||
|
item_count: cart.item_count,
|
||||||
|
items: cart.items,
|
||||||
|
});
|
||||||
|
}, [op]);
|
||||||
|
|
||||||
|
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
|
||||||
|
op.track("checkout_step", {
|
||||||
|
step,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
}, [op]);
|
||||||
|
|
||||||
|
const trackOrderCompleted = useCallback((order: {
|
||||||
|
order_id: string;
|
||||||
|
order_number: string;
|
||||||
|
total: number;
|
||||||
|
currency: string;
|
||||||
|
item_count: number;
|
||||||
|
shipping_cost?: number;
|
||||||
|
customer_email?: string;
|
||||||
|
}) => {
|
||||||
|
op.track("order_completed", {
|
||||||
|
order_id: order.order_id,
|
||||||
|
order_number: order.order_number,
|
||||||
|
total: order.total,
|
||||||
|
currency: order.currency,
|
||||||
|
item_count: order.item_count,
|
||||||
|
shipping_cost: order.shipping_cost,
|
||||||
|
customer_email: order.customer_email,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also track revenue for analytics
|
||||||
|
op.track("purchase", {
|
||||||
|
transaction_id: order.order_number,
|
||||||
|
value: order.total,
|
||||||
|
currency: order.currency,
|
||||||
|
});
|
||||||
|
}, [op]);
|
||||||
|
|
||||||
|
const trackSearch = useCallback((query: string, results_count: number) => {
|
||||||
|
op.track("search", {
|
||||||
|
query,
|
||||||
|
results_count,
|
||||||
|
});
|
||||||
|
}, [op]);
|
||||||
|
|
||||||
|
const trackExternalLink = useCallback((url: string, label?: string) => {
|
||||||
|
op.track("external_link_click", {
|
||||||
|
url,
|
||||||
|
label,
|
||||||
|
});
|
||||||
|
}, [op]);
|
||||||
|
|
||||||
|
const identifyUser = useCallback((user: {
|
||||||
|
profileId: string;
|
||||||
|
email?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
op.identify({
|
||||||
|
profileId: user.profileId,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
email: user.email,
|
||||||
|
properties: user.properties,
|
||||||
|
});
|
||||||
|
}, [op]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackProductView,
|
||||||
|
trackAddToCart,
|
||||||
|
trackRemoveFromCart,
|
||||||
|
trackCheckoutStarted,
|
||||||
|
trackCheckoutStep,
|
||||||
|
trackOrderCompleted,
|
||||||
|
trackSearch,
|
||||||
|
trackExternalLink,
|
||||||
|
identifyUser,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAnalytics;
|
||||||
106
src/lib/resend.ts
Normal file
106
src/lib/resend.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { Resend } from "resend";
|
||||||
|
import { render } from "@react-email/render";
|
||||||
|
|
||||||
|
let resendClient: Resend | null = null;
|
||||||
|
|
||||||
|
function getResendClient(): Resend {
|
||||||
|
if (!resendClient) {
|
||||||
|
if (!process.env.RESEND_API_KEY) {
|
||||||
|
throw new Error("RESEND_API_KEY environment variable is not set");
|
||||||
|
}
|
||||||
|
resendClient = new Resend(process.env.RESEND_API_KEY);
|
||||||
|
}
|
||||||
|
return resendClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ADMIN_EMAILS = ["me@hytham.me", "tamara@hytham.me"];
|
||||||
|
|
||||||
|
export async function sendEmail({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
react,
|
||||||
|
text,
|
||||||
|
tags,
|
||||||
|
idempotencyKey,
|
||||||
|
}: {
|
||||||
|
to: string | string[];
|
||||||
|
subject: string;
|
||||||
|
react: React.ReactNode;
|
||||||
|
text?: string;
|
||||||
|
tags?: { name: string; value: string }[];
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}) {
|
||||||
|
const resend = getResendClient();
|
||||||
|
|
||||||
|
// Render React component to HTML
|
||||||
|
const html = await render(react, {
|
||||||
|
pretty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, error } = await resend.emails.send({
|
||||||
|
from: "ManoonOils <support@mail.manoonoils.com>",
|
||||||
|
replyTo: "support@manoonoils.com",
|
||||||
|
to: Array.isArray(to) ? to : [to],
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text,
|
||||||
|
tags,
|
||||||
|
...(idempotencyKey && { idempotencyKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Failed to send email:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmailToCustomer({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
react,
|
||||||
|
text,
|
||||||
|
language,
|
||||||
|
idempotencyKey,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
react: React.ReactNode;
|
||||||
|
text?: string;
|
||||||
|
language: string;
|
||||||
|
idempotencyKey?: string;
|
||||||
|
}) {
|
||||||
|
const tag = `customer-${language}`;
|
||||||
|
return sendEmail({
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
react,
|
||||||
|
text,
|
||||||
|
tags: [{ name: "type", value: tag }],
|
||||||
|
idempotencyKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendEmailToAdmin({
|
||||||
|
subject,
|
||||||
|
react,
|
||||||
|
text,
|
||||||
|
eventType,
|
||||||
|
orderId,
|
||||||
|
}: {
|
||||||
|
subject: string;
|
||||||
|
react: React.ReactNode;
|
||||||
|
text?: string;
|
||||||
|
eventType: string;
|
||||||
|
orderId: string;
|
||||||
|
}) {
|
||||||
|
return sendEmail({
|
||||||
|
to: ADMIN_EMAILS,
|
||||||
|
subject: `[Admin] ${subject}`,
|
||||||
|
react,
|
||||||
|
text,
|
||||||
|
tags: [{ name: "type", value: "admin-notification" }],
|
||||||
|
idempotencyKey: `admin-${eventType}/${orderId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
81
src/lib/saleor/create-webhooks.graphql
Normal file
81
src/lib/saleor/create-webhooks.graphql
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Replace YOUR_STOREFRONT_URL with your actual storefront URL
|
||||||
|
# Dev: https://dev.manoonoils.com
|
||||||
|
# Prod: https://manoonoils.com
|
||||||
|
|
||||||
|
mutation CreateSaleorWebhooks {
|
||||||
|
orderConfirmedWebhook: webhookCreate(input: {
|
||||||
|
name: "Resend - Order Confirmed"
|
||||||
|
targetUrl: "YOUR_STOREFRONT_URL/api/webhooks/saleor"
|
||||||
|
events: [ORDER_CONFIRMED]
|
||||||
|
isActive: true
|
||||||
|
}) {
|
||||||
|
webhook {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
targetUrl
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orderPaidWebhook: webhookCreate(input: {
|
||||||
|
name: "Resend - Order Paid"
|
||||||
|
targetUrl: "YOUR_STOREFRONT_URL/api/webhooks/saleor"
|
||||||
|
events: [ORDER_FULLY_PAID]
|
||||||
|
isActive: true
|
||||||
|
}) {
|
||||||
|
webhook {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
targetUrl
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orderCancelledWebhook: webhookCreate(input: {
|
||||||
|
name: "Resend - Order Cancelled"
|
||||||
|
targetUrl: "YOUR_STOREFRONT_URL/api/webhooks/saleor"
|
||||||
|
events: [ORDER_CANCELLED]
|
||||||
|
isActive: true
|
||||||
|
}) {
|
||||||
|
webhook {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
targetUrl
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
orderFulfilledWebhook: webhookCreate(input: {
|
||||||
|
name: "Resend - Order Fulfilled"
|
||||||
|
targetUrl: "YOUR_STOREFRONT_URL/api/webhooks/saleor"
|
||||||
|
events: [ORDER_FULFILLED]
|
||||||
|
isActive: true
|
||||||
|
}) {
|
||||||
|
webhook {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
targetUrl
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,18 @@ export const PRODUCT_FRAGMENT = gql`
|
|||||||
key
|
key
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
|
attributes {
|
||||||
|
attribute {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
values {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
${PRODUCT_VARIANT_FRAGMENT}
|
${PRODUCT_VARIANT_FRAGMENT}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export { PRODUCT_VARIANT_FRAGMENT, CHECKOUT_LINE_FRAGMENT } from "./fragments/Va
|
|||||||
export { CHECKOUT_FRAGMENT, ADDRESS_FRAGMENT } from "./fragments/Checkout";
|
export { CHECKOUT_FRAGMENT, ADDRESS_FRAGMENT } from "./fragments/Checkout";
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
export { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_PRODUCTS_BY_CATEGORY } from "./queries/Products";
|
export { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_PRODUCTS_BY_CATEGORY, GET_BUNDLE_PRODUCTS } from "./queries/Products";
|
||||||
export { GET_CHECKOUT, GET_CHECKOUT_BY_ID } from "./queries/Checkout";
|
export { GET_CHECKOUT, GET_CHECKOUT_BY_ID } from "./queries/Checkout";
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
@@ -34,4 +34,9 @@ export {
|
|||||||
formatPrice,
|
formatPrice,
|
||||||
getLocalizedProduct,
|
getLocalizedProduct,
|
||||||
parseDescription,
|
parseDescription,
|
||||||
|
getBundleProducts,
|
||||||
|
getBundleProductsForProduct,
|
||||||
|
getProductBundleComponents,
|
||||||
|
isBundleProduct,
|
||||||
|
filterOutBundles,
|
||||||
} from "./products";
|
} from "./products";
|
||||||
|
|||||||
@@ -152,3 +152,24 @@ export const CHECKOUT_EMAIL_UPDATE = gql`
|
|||||||
}
|
}
|
||||||
${CHECKOUT_FRAGMENT}
|
${CHECKOUT_FRAGMENT}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const CHECKOUT_METADATA_UPDATE = gql`
|
||||||
|
mutation CheckoutMetadataUpdate($checkoutId: ID!, $metadata: [MetadataInput!]!) {
|
||||||
|
updateMetadata(id: $checkoutId, input: $metadata) {
|
||||||
|
item {
|
||||||
|
... on Checkout {
|
||||||
|
id
|
||||||
|
metadata {
|
||||||
|
key
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { saleorClient } from "./client";
|
import { saleorClient } from "./client";
|
||||||
import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG } from "./queries/Products";
|
import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_BUNDLE_PRODUCTS } from "./queries/Products";
|
||||||
import type { Product } from "@/types/saleor";
|
import type { Product } from "@/types/saleor";
|
||||||
|
|
||||||
const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel";
|
const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel";
|
||||||
@@ -155,3 +155,69 @@ export function getLocalizedProduct(
|
|||||||
seoDescription: translation?.seoDescription || product.seoDescription,
|
seoDescription: translation?.seoDescription || product.seoDescription,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProductsResponse {
|
||||||
|
products?: {
|
||||||
|
edges: Array<{ node: Product }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBundleProducts(
|
||||||
|
locale: string = "SR",
|
||||||
|
first: number = 50
|
||||||
|
): Promise<Product[]> {
|
||||||
|
try {
|
||||||
|
const { data } = await saleorClient.query<ProductsResponse>({
|
||||||
|
query: GET_BUNDLE_PRODUCTS,
|
||||||
|
variables: {
|
||||||
|
channel: CHANNEL,
|
||||||
|
locale: locale.toUpperCase(),
|
||||||
|
first,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return data?.products?.edges.map((edge) => edge.node) || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching bundle products from Saleor:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBundleProductsForProduct(
|
||||||
|
allProducts: Product[],
|
||||||
|
baseProductId: string
|
||||||
|
): Product[] {
|
||||||
|
return allProducts.filter((product) => {
|
||||||
|
const bundleItemsAttr = product.attributes?.find(
|
||||||
|
(attr) => attr.attribute.slug === "bundle-items"
|
||||||
|
);
|
||||||
|
if (!bundleItemsAttr) return false;
|
||||||
|
return bundleItemsAttr.values.some((val) => {
|
||||||
|
const referencedId = Buffer.from(val.slug.split(":")[1] || val.id).toString("base64");
|
||||||
|
const expectedId = `UHJvZHVjdDo${baseProductId.split("UHJvZHVjdDo")[1]}`;
|
||||||
|
return referencedId.includes(baseProductId.split("UHJvZHVjdDo")[1] || "") ||
|
||||||
|
val.slug.includes(baseProductId.split("UHJvZHVjdDo")[1] || "");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProductBundleComponents(product: Product): number | null {
|
||||||
|
const bundleAttr = product.attributes?.find(
|
||||||
|
(attr) => attr.attribute.slug === "bundle-items"
|
||||||
|
);
|
||||||
|
if (!bundleAttr) return null;
|
||||||
|
|
||||||
|
const bundleAttrMatch = product.name.match(/(\d+)x/i);
|
||||||
|
if (bundleAttrMatch) {
|
||||||
|
return parseInt(bundleAttrMatch[1], 10);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBundleProduct(product: Product): boolean {
|
||||||
|
return getProductBundleComponents(product) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterOutBundles(products: Product[]): Product[] {
|
||||||
|
return products.filter((product) => !isBundleProduct(product));
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const GET_PRODUCTS = gql`
|
|||||||
products(channel: $channel, first: $first) {
|
products(channel: $channel, first: $first) {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
...ProductListItemFragment
|
...ProductFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pageInfo {
|
pageInfo {
|
||||||
@@ -15,7 +15,7 @@ export const GET_PRODUCTS = gql`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
${PRODUCT_LIST_ITEM_FRAGMENT}
|
${PRODUCT_FRAGMENT}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const GET_PRODUCT_BY_SLUG = gql`
|
export const GET_PRODUCT_BY_SLUG = gql`
|
||||||
@@ -49,3 +49,16 @@ export const GET_PRODUCTS_BY_CATEGORY = gql`
|
|||||||
}
|
}
|
||||||
${PRODUCT_LIST_ITEM_FRAGMENT}
|
${PRODUCT_LIST_ITEM_FRAGMENT}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const GET_BUNDLE_PRODUCTS = gql`
|
||||||
|
query GetBundleProducts($channel: String!, $locale: LanguageCodeEnum!, $first: Int!) {
|
||||||
|
products(channel: $channel, first: $first) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
...ProductFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${PRODUCT_FRAGMENT}
|
||||||
|
`;
|
||||||
|
|||||||
80
src/lib/services/AnalyticsService.ts
Normal file
80
src/lib/services/AnalyticsService.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { OpenPanel } from "@openpanel/nextjs";
|
||||||
|
|
||||||
|
// Initialize OpenPanel for server-side tracking
|
||||||
|
const op = new OpenPanel({
|
||||||
|
clientId: process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || "",
|
||||||
|
clientSecret: process.env.OPENPANEL_CLIENT_SECRET || "",
|
||||||
|
apiUrl: process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api",
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface OrderAnalyticsData {
|
||||||
|
orderId: string;
|
||||||
|
orderNumber: string;
|
||||||
|
total: number;
|
||||||
|
currency: string;
|
||||||
|
itemCount: number;
|
||||||
|
customerEmail: string;
|
||||||
|
eventType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevenueData {
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
orderId: string;
|
||||||
|
orderNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AnalyticsService {
|
||||||
|
private static instance: AnalyticsService;
|
||||||
|
|
||||||
|
static getInstance(): AnalyticsService {
|
||||||
|
if (!AnalyticsService.instance) {
|
||||||
|
AnalyticsService.instance = new AnalyticsService();
|
||||||
|
}
|
||||||
|
return AnalyticsService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
async trackOrderReceived(data: OrderAnalyticsData): Promise<void> {
|
||||||
|
try {
|
||||||
|
await op.track("order_received", {
|
||||||
|
order_id: data.orderId,
|
||||||
|
order_number: data.orderNumber,
|
||||||
|
total: data.total,
|
||||||
|
currency: data.currency,
|
||||||
|
item_count: data.itemCount,
|
||||||
|
customer_email: data.customerEmail,
|
||||||
|
event_type: data.eventType,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to track order received:", error);
|
||||||
|
// Don't throw - analytics should not break the main flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async trackRevenue(data: RevenueData): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log(`Tracking revenue: ${data.amount} ${data.currency} for order ${data.orderNumber}`);
|
||||||
|
await op.revenue(data.amount, {
|
||||||
|
currency: data.currency,
|
||||||
|
order_id: data.orderId,
|
||||||
|
order_number: data.orderNumber,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to track revenue:", error);
|
||||||
|
// Don't throw - analytics should not break the main flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async track(eventName: string, properties: Record<string, unknown>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await op.track(eventName, properties);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to track event ${eventName}:`, error);
|
||||||
|
// Don't throw - analytics should not break the main flow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const analyticsService = AnalyticsService.getInstance();
|
||||||
|
export { AnalyticsService };
|
||||||
|
export default analyticsService;
|
||||||
@@ -58,22 +58,24 @@ interface GetCheckoutResponse {
|
|||||||
interface SaleorCheckoutStore {
|
interface SaleorCheckoutStore {
|
||||||
checkout: Checkout | null;
|
checkout: Checkout | null;
|
||||||
checkoutToken: string | null;
|
checkoutToken: string | null;
|
||||||
|
languageCode: string | null;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
initCheckout: () => Promise<void>;
|
initCheckout: () => Promise<void>;
|
||||||
addLine: (variantId: string, quantity: number) => Promise<void>;
|
addLine: (variantId: string, quantity: number) => Promise<void>;
|
||||||
updateLine: (lineId: string, quantity: number) => Promise<void>;
|
updateLine: (lineId: string, quantity: number) => Promise<void>;
|
||||||
removeLine: (lineId: string) => Promise<void>;
|
removeLine: (lineId: string) => Promise<void>;
|
||||||
setEmail: (email: string) => Promise<void>;
|
setEmail: (email: string) => Promise<void>;
|
||||||
|
setLanguageCode: (languageCode: string) => void;
|
||||||
refreshCheckout: () => Promise<void>;
|
refreshCheckout: () => Promise<void>;
|
||||||
toggleCart: () => void;
|
toggleCart: () => void;
|
||||||
openCart: () => void;
|
openCart: () => void;
|
||||||
closeCart: () => void;
|
closeCart: () => void;
|
||||||
clearError: () => void;
|
clearError: () => void;
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
getLineCount: () => number;
|
getLineCount: () => number;
|
||||||
getTotal: () => number;
|
getTotal: () => number;
|
||||||
@@ -85,13 +87,14 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
|||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
checkout: null,
|
checkout: null,
|
||||||
checkoutToken: null,
|
checkoutToken: null,
|
||||||
|
languageCode: null,
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
initCheckout: async () => {
|
initCheckout: async () => {
|
||||||
const { checkoutToken } = get();
|
const { checkoutToken, languageCode } = get();
|
||||||
|
|
||||||
if (checkoutToken) {
|
if (checkoutToken) {
|
||||||
// Try to fetch existing checkout
|
// Try to fetch existing checkout
|
||||||
try {
|
try {
|
||||||
@@ -99,7 +102,7 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
|||||||
query: GET_CHECKOUT,
|
query: GET_CHECKOUT,
|
||||||
variables: { token: checkoutToken },
|
variables: { token: checkoutToken },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data?.checkout) {
|
if (data?.checkout) {
|
||||||
set({ checkout: data.checkout });
|
set({ checkout: data.checkout });
|
||||||
return;
|
return;
|
||||||
@@ -108,8 +111,8 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
|||||||
// Checkout not found or expired, create new one
|
// Checkout not found or expired, create new one
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new checkout
|
// Create new checkout with language code
|
||||||
try {
|
try {
|
||||||
const { data } = await saleorClient.mutate<CheckoutCreateResponse>({
|
const { data } = await saleorClient.mutate<CheckoutCreateResponse>({
|
||||||
mutation: CHECKOUT_CREATE,
|
mutation: CHECKOUT_CREATE,
|
||||||
@@ -117,10 +120,11 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
|||||||
input: {
|
input: {
|
||||||
channel: CHANNEL,
|
channel: CHANNEL,
|
||||||
lines: [],
|
lines: [],
|
||||||
|
languageCode: languageCode ? languageCode.toUpperCase() : undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (data?.checkoutCreate?.checkout) {
|
if (data?.checkoutCreate?.checkout) {
|
||||||
set({
|
set({
|
||||||
checkout: data.checkoutCreate.checkout,
|
checkout: data.checkoutCreate.checkout,
|
||||||
@@ -294,6 +298,7 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
|
|||||||
openCart: () => set({ isOpen: true }),
|
openCart: () => set({ isOpen: true }),
|
||||||
closeCart: () => set({ isOpen: false }),
|
closeCart: () => set({ isOpen: false }),
|
||||||
clearError: () => set({ error: null }),
|
clearError: () => set({ error: null }),
|
||||||
|
setLanguageCode: (languageCode: string) => set({ languageCode }),
|
||||||
|
|
||||||
getLineCount: () => {
|
getLineCount: () => {
|
||||||
const { checkout } = get();
|
const { checkout } = get();
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ export interface ProductMedia {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductAttributeValue {
|
export interface ProductAttributeValue {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductAttribute {
|
export interface ProductAttribute {
|
||||||
attribute: {
|
attribute: {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
};
|
};
|
||||||
@@ -82,6 +84,7 @@ export interface Product {
|
|||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
}[];
|
}[];
|
||||||
|
attributes?: ProductAttribute[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductEdge {
|
export interface ProductEdge {
|
||||||
|
|||||||
36
vitest.config.ts
Normal file
36
vitest.config.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ["./src/__tests__/setup.ts"],
|
||||||
|
coverage: {
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text", "json", "html"],
|
||||||
|
thresholds: {
|
||||||
|
lines: 80,
|
||||||
|
functions: 80,
|
||||||
|
branches: 80,
|
||||||
|
statements: 80,
|
||||||
|
},
|
||||||
|
exclude: [
|
||||||
|
"node_modules/",
|
||||||
|
"src/__tests__/",
|
||||||
|
"**/*.d.ts",
|
||||||
|
"**/*.config.*",
|
||||||
|
"**/e2e/**",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||||
|
exclude: ["node_modules", "dist", ".next", "e2e"],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user