Compare commits
3 Commits
test/suite
...
refactor/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a419337d99 | ||
|
|
09294fd752 | ||
|
|
a6ebcf408c |
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*
|
||||||
@@ -244,7 +244,7 @@ export default function CheckoutPage() {
|
|||||||
}
|
}
|
||||||
console.log("Step 2: Shipping method set successfully");
|
console.log("Step 2: Shipping method set successfully");
|
||||||
|
|
||||||
console.log("Step 3: Saving phone number...");
|
console.log("Step 3: Saving metadata...");
|
||||||
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
|
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
|
||||||
mutation: CHECKOUT_METADATA_UPDATE,
|
mutation: CHECKOUT_METADATA_UPDATE,
|
||||||
variables: {
|
variables: {
|
||||||
@@ -252,6 +252,8 @@ export default function CheckoutPage() {
|
|||||||
metadata: [
|
metadata: [
|
||||||
{ key: "phone", value: shippingAddress.phone },
|
{ key: "phone", value: shippingAddress.phone },
|
||||||
{ key: "shippingPhone", value: shippingAddress.phone },
|
{ key: "shippingPhone", value: shippingAddress.phone },
|
||||||
|
{ key: "userLanguage", value: locale },
|
||||||
|
{ key: "userLocale", value: locale },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -316,7 +318,16 @@ export default function CheckoutPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
|
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
|
||||||
throw new Error(`Email update failed: ${emailResult.data.checkoutEmailUpdate.errors[0].message}`);
|
const errorMessage = emailResult.data.checkoutEmailUpdate.errors[0].message;
|
||||||
|
// Check if checkout no longer exists
|
||||||
|
if (errorMessage.includes("Couldn't resolve to a node")) {
|
||||||
|
console.error("Checkout not found, clearing cart...");
|
||||||
|
localStorage.removeItem('cart');
|
||||||
|
localStorage.removeItem('checkoutId');
|
||||||
|
window.location.href = `/${locale}/products`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`Email update failed: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
console.log("Step 1: Email updated successfully");
|
console.log("Step 1: Email updated successfully");
|
||||||
|
|
||||||
|
|||||||
@@ -1,295 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
import { orderNotificationService } from "@/lib/services/OrderNotificationService";
|
|
||||||
import { analyticsService } from "@/lib/services/AnalyticsService";
|
|
||||||
|
|
||||||
// Saleor webhook payload interfaces (snake_case)
|
|
||||||
interface SaleorLineItemPayload {
|
|
||||||
id: string;
|
|
||||||
product_name: string;
|
|
||||||
variant_name?: string;
|
|
||||||
quantity: number;
|
|
||||||
total_price_gross_amount: string;
|
|
||||||
currency: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SaleorAddressPayload {
|
|
||||||
first_name?: string;
|
|
||||||
last_name?: string;
|
|
||||||
street_address_1?: string;
|
|
||||||
street_address_2?: string;
|
|
||||||
city?: string;
|
|
||||||
postal_code?: string;
|
|
||||||
country?: string;
|
|
||||||
phone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SaleorOrderPayload {
|
|
||||||
id: string;
|
|
||||||
number: number;
|
|
||||||
user_email: string;
|
|
||||||
first_name?: string;
|
|
||||||
last_name?: string;
|
|
||||||
billing_address?: SaleorAddressPayload;
|
|
||||||
shipping_address?: SaleorAddressPayload;
|
|
||||||
lines: SaleorLineItemPayload[];
|
|
||||||
total_gross_amount: string;
|
|
||||||
shipping_price_gross_amount?: string;
|
|
||||||
channel: { currency_code: string };
|
|
||||||
currency?: string;
|
|
||||||
language_code?: string;
|
|
||||||
metadata?: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Internal camelCase interfaces
|
|
||||||
interface OrderItem {
|
|
||||||
id: string;
|
|
||||||
productName: string;
|
|
||||||
variantName?: string;
|
|
||||||
quantity: number;
|
|
||||||
totalPrice: {
|
|
||||||
gross: { amount: number; currency: string };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderAddress {
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
streetAddress1?: string;
|
|
||||||
streetAddress2?: string;
|
|
||||||
city?: string;
|
|
||||||
postalCode?: string;
|
|
||||||
country?: string;
|
|
||||||
phone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Order {
|
|
||||||
id: string;
|
|
||||||
number: string;
|
|
||||||
userEmail: string;
|
|
||||||
user?: { firstName?: string; lastName?: string };
|
|
||||||
billingAddress?: OrderAddress;
|
|
||||||
shippingAddress?: OrderAddress;
|
|
||||||
lines: OrderItem[];
|
|
||||||
total: { gross: { amount: number; currency: string } };
|
|
||||||
languageCode?: string;
|
|
||||||
metadata?: Array<{ key: string; value: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SUPPORTED_EVENTS = [
|
|
||||||
"ORDER_CREATED",
|
|
||||||
"ORDER_CONFIRMED",
|
|
||||||
"ORDER_FULLY_PAID",
|
|
||||||
"ORDER_CANCELLED",
|
|
||||||
"ORDER_FULFILLED",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Convert Saleor payload to internal format
|
|
||||||
function convertPayloadToOrder(payload: SaleorOrderPayload): Order {
|
|
||||||
return {
|
|
||||||
id: payload.id,
|
|
||||||
number: String(payload.number),
|
|
||||||
userEmail: payload.user_email,
|
|
||||||
user: payload.first_name || payload.last_name ? {
|
|
||||||
firstName: payload.first_name,
|
|
||||||
lastName: payload.last_name,
|
|
||||||
} : undefined,
|
|
||||||
billingAddress: payload.billing_address ? {
|
|
||||||
firstName: payload.billing_address.first_name,
|
|
||||||
lastName: payload.billing_address.last_name,
|
|
||||||
streetAddress1: payload.billing_address.street_address_1,
|
|
||||||
streetAddress2: payload.billing_address.street_address_2,
|
|
||||||
city: payload.billing_address.city,
|
|
||||||
postalCode: payload.billing_address.postal_code,
|
|
||||||
country: payload.billing_address.country,
|
|
||||||
phone: payload.billing_address.phone,
|
|
||||||
} : undefined,
|
|
||||||
shippingAddress: payload.shipping_address ? {
|
|
||||||
firstName: payload.shipping_address.first_name,
|
|
||||||
lastName: payload.shipping_address.last_name,
|
|
||||||
streetAddress1: payload.shipping_address.street_address_1,
|
|
||||||
streetAddress2: payload.shipping_address.street_address_2,
|
|
||||||
city: payload.shipping_address.city,
|
|
||||||
postalCode: payload.shipping_address.postal_code,
|
|
||||||
country: payload.shipping_address.country,
|
|
||||||
phone: payload.shipping_address.phone,
|
|
||||||
} : undefined,
|
|
||||||
lines: payload.lines.map((line) => ({
|
|
||||||
id: line.id,
|
|
||||||
productName: line.product_name,
|
|
||||||
variantName: line.variant_name,
|
|
||||||
quantity: line.quantity,
|
|
||||||
totalPrice: {
|
|
||||||
gross: {
|
|
||||||
amount: parseInt(line.total_price_gross_amount),
|
|
||||||
currency: line.currency || payload.channel.currency_code,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
total: {
|
|
||||||
gross: {
|
|
||||||
amount: parseInt(payload.total_gross_amount),
|
|
||||||
currency: payload.channel.currency_code,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
languageCode: payload.language_code?.toUpperCase(),
|
|
||||||
metadata: payload.metadata ? Object.entries(payload.metadata).map(([key, value]) => ({ key, value })) : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract tracking number from metadata
|
|
||||||
function getTrackingInfo(order: Order): { trackingNumber?: string; trackingUrl?: string } {
|
|
||||||
if (!order.metadata) return {};
|
|
||||||
|
|
||||||
const trackingMeta = order.metadata.find((m) => m.key === "trackingNumber");
|
|
||||||
const trackingUrlMeta = order.metadata.find((m) => m.key === "trackingUrl");
|
|
||||||
|
|
||||||
return {
|
|
||||||
trackingNumber: trackingMeta?.value,
|
|
||||||
trackingUrl: trackingUrlMeta?.value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract cancellation reason from metadata
|
|
||||||
function getCancellationReason(order: Order): string | undefined {
|
|
||||||
if (!order.metadata) return undefined;
|
|
||||||
const reasonMeta = order.metadata.find((m) => m.key === "cancellationReason");
|
|
||||||
return reasonMeta?.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Webhook handlers
|
|
||||||
async function handleOrderConfirmed(order: Order, eventType: string) {
|
|
||||||
const itemCount = order.lines.reduce((sum, line) => sum + line.quantity, 0);
|
|
||||||
|
|
||||||
// Send customer email only for ORDER_CONFIRMED (not ORDER_CREATED)
|
|
||||||
if (eventType === "ORDER_CONFIRMED") {
|
|
||||||
await orderNotificationService.sendOrderConfirmation(order);
|
|
||||||
|
|
||||||
// Track revenue and order analytics only on ORDER_CONFIRMED (not ORDER_CREATED)
|
|
||||||
// This prevents duplicate tracking when both events fire for the same order
|
|
||||||
analyticsService.trackOrderReceived({
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
total: order.total.gross.amount,
|
|
||||||
currency: order.total.gross.currency,
|
|
||||||
itemCount,
|
|
||||||
customerEmail: order.userEmail,
|
|
||||||
eventType,
|
|
||||||
});
|
|
||||||
|
|
||||||
analyticsService.trackRevenue({
|
|
||||||
amount: order.total.gross.amount,
|
|
||||||
currency: order.total.gross.currency,
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send admin notification for both events
|
|
||||||
await orderNotificationService.sendOrderConfirmationToAdmin(order);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleOrderFulfilled(order: Order) {
|
|
||||||
const { trackingNumber, trackingUrl } = getTrackingInfo(order);
|
|
||||||
|
|
||||||
await orderNotificationService.sendOrderShipped(order, trackingNumber, trackingUrl);
|
|
||||||
await orderNotificationService.sendOrderShippedToAdmin(order, trackingNumber, trackingUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleOrderCancelled(order: Order) {
|
|
||||||
const reason = getCancellationReason(order);
|
|
||||||
|
|
||||||
await orderNotificationService.sendOrderCancelled(order, reason);
|
|
||||||
await orderNotificationService.sendOrderCancelledToAdmin(order, reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleOrderFullyPaid(order: Order) {
|
|
||||||
await orderNotificationService.sendOrderPaid(order);
|
|
||||||
await orderNotificationService.sendOrderPaidToAdmin(order);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main webhook processor
|
|
||||||
async function processWebhook(event: string, order: Order) {
|
|
||||||
console.log(`Processing webhook event: ${event} for order ${order.id}`);
|
|
||||||
|
|
||||||
switch (event) {
|
|
||||||
case "ORDER_CREATED":
|
|
||||||
case "ORDER_CONFIRMED":
|
|
||||||
await handleOrderConfirmed(order, event);
|
|
||||||
break;
|
|
||||||
case "ORDER_FULFILLED":
|
|
||||||
await handleOrderFulfilled(order);
|
|
||||||
break;
|
|
||||||
case "ORDER_CANCELLED":
|
|
||||||
await handleOrderCancelled(order);
|
|
||||||
break;
|
|
||||||
case "ORDER_FULLY_PAID":
|
|
||||||
await handleOrderFullyPaid(order);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.log(`Unsupported event: ${event}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
console.log("=== WEBHOOK RECEIVED ===");
|
|
||||||
console.log("Timestamp:", new Date().toISOString());
|
|
||||||
|
|
||||||
const body = await request.json();
|
|
||||||
const headers = request.headers;
|
|
||||||
|
|
||||||
const event = headers.get("saleor-event") as string;
|
|
||||||
const domain = headers.get("saleor-domain");
|
|
||||||
|
|
||||||
console.log(`Received webhook: ${event} from ${domain}`);
|
|
||||||
|
|
||||||
// Parse payload
|
|
||||||
let orderPayload: SaleorOrderPayload | null = null;
|
|
||||||
if (Array.isArray(body) && body.length > 0) {
|
|
||||||
orderPayload = body[0] as SaleorOrderPayload;
|
|
||||||
} else if (body.data && Array.isArray(body.data)) {
|
|
||||||
orderPayload = body.data[0] as SaleorOrderPayload;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!orderPayload) {
|
|
||||||
console.error("No order found in webhook payload");
|
|
||||||
return NextResponse.json({ error: "No order in payload" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Order:", {
|
|
||||||
id: orderPayload.id,
|
|
||||||
number: orderPayload.number,
|
|
||||||
email: orderPayload.user_email,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!event) {
|
|
||||||
return NextResponse.json({ error: "Missing saleor-event header" }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedEvent = event.toUpperCase();
|
|
||||||
|
|
||||||
if (!SUPPORTED_EVENTS.includes(normalizedEvent)) {
|
|
||||||
console.log(`Event ${event} not supported, skipping`);
|
|
||||||
return NextResponse.json({ success: true, message: "Event not supported" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const order = convertPayloadToOrder(orderPayload);
|
|
||||||
await processWebhook(normalizedEvent, order);
|
|
||||||
|
|
||||||
return NextResponse.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Webhook processing error:", error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Internal server error", details: String(error) },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
return NextResponse.json({
|
|
||||||
status: "ok",
|
|
||||||
message: "Saleor webhook endpoint is active",
|
|
||||||
supportedEvents: SUPPORTED_EVENTS,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export function formatPrice(amount: number, currency: string): string {
|
|
||||||
return new Intl.NumberFormat("sr-RS", {
|
|
||||||
style: "currency",
|
|
||||||
currency: currency,
|
|
||||||
}).format(amount);
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Button,
|
|
||||||
Container,
|
|
||||||
Head,
|
|
||||||
Hr,
|
|
||||||
Html,
|
|
||||||
Img,
|
|
||||||
Link,
|
|
||||||
Preview,
|
|
||||||
Section,
|
|
||||||
Text,
|
|
||||||
} from "@react-email/components";
|
|
||||||
|
|
||||||
interface BaseLayoutProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
previewText: string;
|
|
||||||
language: string;
|
|
||||||
siteUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const translations: Record<string, { footer: string; company: string }> = {
|
|
||||||
sr: {
|
|
||||||
footer: "ManoonOils - Prirodna kozmetika | www.manoonoils.com",
|
|
||||||
company: "ManoonOils",
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
footer: "ManoonOils - Natural Cosmetics | www.manoonoils.com",
|
|
||||||
company: "ManoonOils",
|
|
||||||
},
|
|
||||||
de: {
|
|
||||||
footer: "ManoonOils - Natürliche Kosmetik | www.manoonoils.com",
|
|
||||||
company: "ManoonOils",
|
|
||||||
},
|
|
||||||
fr: {
|
|
||||||
footer: "ManoonOils - Cosmétiques Naturels | www.manoonoils.com",
|
|
||||||
company: "ManoonOils",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function BaseLayout({ children, previewText, language, siteUrl }: BaseLayoutProps) {
|
|
||||||
const t = translations[language] || translations.en;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Html>
|
|
||||||
<Head />
|
|
||||||
<Preview>{previewText}</Preview>
|
|
||||||
<Body style={styles.body}>
|
|
||||||
<Container style={styles.container}>
|
|
||||||
<Section style={styles.logoSection}>
|
|
||||||
<Img
|
|
||||||
src="https://minio-api.nodecrew.me/manoon-media/2024/09/cropped-manoon-logo_256x-1-1.png"
|
|
||||||
width="150"
|
|
||||||
height="auto"
|
|
||||||
alt="ManoonOils"
|
|
||||||
style={styles.logo}
|
|
||||||
/>
|
|
||||||
</Section>
|
|
||||||
{children}
|
|
||||||
<Section style={styles.footer}>
|
|
||||||
<Text style={styles.footerText}>{t.footer}</Text>
|
|
||||||
</Section>
|
|
||||||
</Container>
|
|
||||||
</Body>
|
|
||||||
</Html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
body: {
|
|
||||||
backgroundColor: "#f6f6f6",
|
|
||||||
fontFamily:
|
|
||||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
|
||||||
},
|
|
||||||
container: {
|
|
||||||
backgroundColor: "#ffffff",
|
|
||||||
margin: "0 auto",
|
|
||||||
padding: "40px 20px",
|
|
||||||
maxWidth: "600px",
|
|
||||||
},
|
|
||||||
logoSection: {
|
|
||||||
textAlign: "center" as const,
|
|
||||||
marginBottom: "30px",
|
|
||||||
},
|
|
||||||
logo: {
|
|
||||||
margin: "0 auto",
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
marginTop: "40px",
|
|
||||||
paddingTop: "20px",
|
|
||||||
borderTop: "1px solid #e0e0e0",
|
|
||||||
},
|
|
||||||
footerText: {
|
|
||||||
color: "#666666",
|
|
||||||
fontSize: "12px",
|
|
||||||
textAlign: "center" as const,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
import { Button, Hr, Section, Text } from "@react-email/components";
|
|
||||||
import { BaseLayout } from "./BaseLayout";
|
|
||||||
|
|
||||||
interface OrderItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
quantity: number;
|
|
||||||
price: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderCancelledProps {
|
|
||||||
language: string;
|
|
||||||
orderId: string;
|
|
||||||
orderNumber: string;
|
|
||||||
customerName: string;
|
|
||||||
items: OrderItem[];
|
|
||||||
total: string;
|
|
||||||
reason?: string;
|
|
||||||
siteUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const translations: Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
title: string;
|
|
||||||
preview: string;
|
|
||||||
greeting: string;
|
|
||||||
orderCancelled: string;
|
|
||||||
items: string;
|
|
||||||
total: string;
|
|
||||||
reason: string;
|
|
||||||
questions: string;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
sr: {
|
|
||||||
title: "Vaša narudžbina je otkazana",
|
|
||||||
preview: "Vaša narudžbina je otkazana",
|
|
||||||
greeting: "Poštovani {name},",
|
|
||||||
orderCancelled:
|
|
||||||
"Vaša narudžbina je otkazana. Ako niste zatražili otkazivanje, molimo kontaktirajte nas što pre.",
|
|
||||||
items: "Artikli",
|
|
||||||
total: "Ukupno",
|
|
||||||
reason: "Razlog",
|
|
||||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
title: "Your Order Has Been Cancelled",
|
|
||||||
preview: "Your order has been cancelled",
|
|
||||||
greeting: "Dear {name},",
|
|
||||||
orderCancelled:
|
|
||||||
"Your order has been cancelled. If you did not request this cancellation, please contact us as soon as possible.",
|
|
||||||
items: "Items",
|
|
||||||
total: "Total",
|
|
||||||
reason: "Reason",
|
|
||||||
questions: "Questions? Email us at support@manoonoils.com",
|
|
||||||
},
|
|
||||||
de: {
|
|
||||||
title: "Ihre Bestellung wurde storniert",
|
|
||||||
preview: "Ihre Bestellung wurde storniert",
|
|
||||||
greeting: "Sehr geehrte/r {name},",
|
|
||||||
orderCancelled:
|
|
||||||
"Ihre Bestellung wurde storniert. Wenn Sie diese Stornierung nicht angefordert haben, kontaktieren Sie uns bitte so schnell wie möglich.",
|
|
||||||
items: "Artikel",
|
|
||||||
total: "Gesamt",
|
|
||||||
reason: "Grund",
|
|
||||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
|
||||||
},
|
|
||||||
fr: {
|
|
||||||
title: "Votre commande a été annulée",
|
|
||||||
preview: "Votre commande a été annulée",
|
|
||||||
greeting: "Cher(e) {name},",
|
|
||||||
orderCancelled:
|
|
||||||
"Votre commande a été annulée. Si vous n'avez pas demandé cette annulation, veuillez nous contacter dès que possible.",
|
|
||||||
items: "Articles",
|
|
||||||
total: "Total",
|
|
||||||
reason: "Raison",
|
|
||||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OrderCancelled({
|
|
||||||
language = "en",
|
|
||||||
orderId,
|
|
||||||
orderNumber,
|
|
||||||
customerName,
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
reason,
|
|
||||||
siteUrl,
|
|
||||||
}: OrderCancelledProps) {
|
|
||||||
const t = translations[language] || translations.en;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
|
||||||
<Text style={styles.title}>{t.title}</Text>
|
|
||||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
|
||||||
<Text style={styles.text}>{t.orderCancelled}</Text>
|
|
||||||
|
|
||||||
<Section style={styles.orderInfo}>
|
|
||||||
<Text style={styles.orderNumber}>
|
|
||||||
<strong>Order Number:</strong> {orderNumber}
|
|
||||||
</Text>
|
|
||||||
{reason && (
|
|
||||||
<Text style={styles.reason}>
|
|
||||||
<strong>{t.reason}:</strong> {reason}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section style={styles.itemsSection}>
|
|
||||||
<Text style={styles.sectionTitle}>{t.items}</Text>
|
|
||||||
<Hr style={styles.hr} />
|
|
||||||
{items.map((item) => (
|
|
||||||
<Section key={item.id} style={styles.itemRow}>
|
|
||||||
<Text style={styles.itemName}>
|
|
||||||
{item.quantity}x {item.name}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
|
||||||
</Section>
|
|
||||||
))}
|
|
||||||
<Hr style={styles.hr} />
|
|
||||||
<Section style={styles.totalRow}>
|
|
||||||
<Text style={styles.totalLabel}>{t.total}:</Text>
|
|
||||||
<Text style={styles.totalValue}>{total}</Text>
|
|
||||||
</Section>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section style={styles.buttonSection}>
|
|
||||||
<Button href={siteUrl} style={styles.button}>
|
|
||||||
{language === "sr" ? "Pogledajte proizvode" : "Browse Products"}
|
|
||||||
</Button>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Text style={styles.questions}>{t.questions}</Text>
|
|
||||||
</BaseLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
title: {
|
|
||||||
fontSize: "24px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#dc2626",
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
greeting: {
|
|
||||||
fontSize: "16px",
|
|
||||||
color: "#333333",
|
|
||||||
marginBottom: "10px",
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#666666",
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
orderInfo: {
|
|
||||||
backgroundColor: "#fef2f2",
|
|
||||||
padding: "15px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
orderNumber: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#333333",
|
|
||||||
margin: "0 0 5px 0",
|
|
||||||
},
|
|
||||||
reason: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#991b1b",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
itemsSection: {
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: "16px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#1a1a1a",
|
|
||||||
marginBottom: "10px",
|
|
||||||
},
|
|
||||||
hr: {
|
|
||||||
borderColor: "#e0e0e0",
|
|
||||||
margin: "10px 0",
|
|
||||||
},
|
|
||||||
itemRow: {
|
|
||||||
display: "flex" as const,
|
|
||||||
justifyContent: "space-between" as const,
|
|
||||||
padding: "8px 0",
|
|
||||||
},
|
|
||||||
itemName: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#666666",
|
|
||||||
margin: "0",
|
|
||||||
textDecoration: "line-through",
|
|
||||||
},
|
|
||||||
itemPrice: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#666666",
|
|
||||||
margin: "0",
|
|
||||||
textDecoration: "line-through",
|
|
||||||
},
|
|
||||||
totalRow: {
|
|
||||||
display: "flex" as const,
|
|
||||||
justifyContent: "space-between" as const,
|
|
||||||
padding: "8px 0",
|
|
||||||
},
|
|
||||||
totalLabel: {
|
|
||||||
fontSize: "16px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#666666",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
totalValue: {
|
|
||||||
fontSize: "16px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#666666",
|
|
||||||
margin: "0",
|
|
||||||
textDecoration: "line-through",
|
|
||||||
},
|
|
||||||
buttonSection: {
|
|
||||||
textAlign: "center" as const,
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
backgroundColor: "#000000",
|
|
||||||
color: "#ffffff",
|
|
||||||
padding: "12px 30px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
textDecoration: "none",
|
|
||||||
},
|
|
||||||
questions: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#666666",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
import { Button, Hr, Section, Text } from "@react-email/components";
|
|
||||||
import { BaseLayout } from "./BaseLayout";
|
|
||||||
|
|
||||||
interface OrderItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
quantity: number;
|
|
||||||
price: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderConfirmationProps {
|
|
||||||
language: string;
|
|
||||||
orderId: string;
|
|
||||||
orderNumber: string;
|
|
||||||
customerEmail: string;
|
|
||||||
customerName: string;
|
|
||||||
items: OrderItem[];
|
|
||||||
total: string;
|
|
||||||
shippingAddress?: string;
|
|
||||||
billingAddress?: string;
|
|
||||||
phone?: string;
|
|
||||||
siteUrl: string;
|
|
||||||
dashboardUrl?: string;
|
|
||||||
isAdmin?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const translations: Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
title: string;
|
|
||||||
preview: string;
|
|
||||||
greeting: string;
|
|
||||||
orderReceived: string;
|
|
||||||
orderNumber: string;
|
|
||||||
items: string;
|
|
||||||
quantity: string;
|
|
||||||
total: string;
|
|
||||||
shippingTo: string;
|
|
||||||
questions: string;
|
|
||||||
thankYou: string;
|
|
||||||
adminTitle: string;
|
|
||||||
adminPreview: string;
|
|
||||||
adminGreeting: string;
|
|
||||||
adminMessage: string;
|
|
||||||
customerLabel: string;
|
|
||||||
customerEmailLabel: string;
|
|
||||||
billingAddressLabel: string;
|
|
||||||
phoneLabel: string;
|
|
||||||
viewDashboard: string;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
sr: {
|
|
||||||
title: "Potvrda narudžbine",
|
|
||||||
preview: "Vaša narudžbina je potvrđena",
|
|
||||||
greeting: "Poštovani {name},",
|
|
||||||
orderReceived: "Zahvaljujemo se na Vašoj narudžbini! Primili smo je i sada je u pripremi.",
|
|
||||||
orderNumber: "Broj narudžbine",
|
|
||||||
items: "Artikli",
|
|
||||||
quantity: "Količina",
|
|
||||||
total: "Ukupno",
|
|
||||||
shippingTo: "Adresa za dostavu",
|
|
||||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
|
||||||
thankYou: "Hvala Vam što kupujete kod nas!",
|
|
||||||
adminTitle: "Nova narudžbina!",
|
|
||||||
adminPreview: "Nova narudžbina je primljena",
|
|
||||||
adminGreeting: "Čestitamo na prodaji!",
|
|
||||||
adminMessage: "Nova narudžbina je upravo primljena. Detalji su ispod:",
|
|
||||||
customerLabel: "Kupac",
|
|
||||||
customerEmailLabel: "Email kupca",
|
|
||||||
billingAddressLabel: "Adresa za naplatu",
|
|
||||||
phoneLabel: "Telefon",
|
|
||||||
viewDashboard: "Pogledaj u Dashboardu",
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
title: "Order Confirmation",
|
|
||||||
preview: "Your order has been confirmed",
|
|
||||||
greeting: "Dear {name},",
|
|
||||||
orderReceived:
|
|
||||||
"Thank you for your order! We have received it and it is now being processed.",
|
|
||||||
orderNumber: "Order number",
|
|
||||||
items: "Items",
|
|
||||||
quantity: "Quantity",
|
|
||||||
total: "Total",
|
|
||||||
shippingTo: "Shipping address",
|
|
||||||
questions: "Questions? Email us at support@manoonoils.com",
|
|
||||||
thankYou: "Thank you for shopping with us!",
|
|
||||||
adminTitle: "New Order! 🎉",
|
|
||||||
adminPreview: "A new order has been received",
|
|
||||||
adminGreeting: "Congratulations on the sale!",
|
|
||||||
adminMessage: "A new order has just been placed. Details below:",
|
|
||||||
customerLabel: "Customer",
|
|
||||||
customerEmailLabel: "Customer Email",
|
|
||||||
billingAddressLabel: "Billing Address",
|
|
||||||
phoneLabel: "Phone",
|
|
||||||
viewDashboard: "View in Dashboard",
|
|
||||||
},
|
|
||||||
de: {
|
|
||||||
title: "Bestellungsbestätigung",
|
|
||||||
preview: "Ihre Bestellung wurde bestätigt",
|
|
||||||
greeting: "Sehr geehrte/r {name},",
|
|
||||||
orderReceived:
|
|
||||||
"Vielen Dank für Ihre Bestellung! Wir haben sie erhalten und sie wird nun bearbeitet.",
|
|
||||||
orderNumber: "Bestellnummer",
|
|
||||||
items: "Artikel",
|
|
||||||
quantity: "Menge",
|
|
||||||
total: "Gesamt",
|
|
||||||
shippingTo: "Lieferadresse",
|
|
||||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
|
||||||
thankYou: "Vielen Dank für Ihren Einkauf!",
|
|
||||||
adminTitle: "Neue Bestellung! 🎉",
|
|
||||||
adminPreview: "Eine neue Bestellung wurde erhalten",
|
|
||||||
adminGreeting: "Glückwunsch zum Verkauf!",
|
|
||||||
adminMessage: "Eine neue Bestellung wurde soeben aufgegeben. Details unten:",
|
|
||||||
customerLabel: "Kunde",
|
|
||||||
customerEmailLabel: "Kunden-E-Mail",
|
|
||||||
billingAddressLabel: "Rechnungsadresse",
|
|
||||||
phoneLabel: "Telefon",
|
|
||||||
viewDashboard: "Im Dashboard anzeigen",
|
|
||||||
},
|
|
||||||
fr: {
|
|
||||||
title: "Confirmation de commande",
|
|
||||||
preview: "Votre commande a été confirmée",
|
|
||||||
greeting: "Cher(e) {name},",
|
|
||||||
orderReceived:
|
|
||||||
"Merci pour votre commande! Nous l'avons reçue et elle est en cours de traitement.",
|
|
||||||
orderNumber: "Numéro de commande",
|
|
||||||
items: "Articles",
|
|
||||||
quantity: "Quantité",
|
|
||||||
total: "Total",
|
|
||||||
shippingTo: "Adresse de livraison",
|
|
||||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
|
||||||
thankYou: "Merci d'avoir Magasiné avec nous!",
|
|
||||||
adminTitle: "Nouvelle commande! 🎉",
|
|
||||||
adminPreview: "Une nouvelle commande a été reçue",
|
|
||||||
adminGreeting: "Félicitations pour la vente!",
|
|
||||||
adminMessage: "Une nouvelle commande vient d'être passée. Détails ci-dessous:",
|
|
||||||
customerLabel: "Client",
|
|
||||||
customerEmailLabel: "Email du client",
|
|
||||||
billingAddressLabel: "Adresse de facturation",
|
|
||||||
phoneLabel: "Téléphone",
|
|
||||||
viewDashboard: "Voir dans le Dashboard",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OrderConfirmation({
|
|
||||||
language = "en",
|
|
||||||
orderId,
|
|
||||||
orderNumber,
|
|
||||||
customerEmail,
|
|
||||||
customerName,
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
shippingAddress,
|
|
||||||
billingAddress,
|
|
||||||
phone,
|
|
||||||
siteUrl,
|
|
||||||
dashboardUrl,
|
|
||||||
isAdmin = false,
|
|
||||||
}: OrderConfirmationProps) {
|
|
||||||
const t = translations[language] || translations.en;
|
|
||||||
|
|
||||||
// For admin emails, always use English
|
|
||||||
const adminT = translations["en"];
|
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
return (
|
|
||||||
<BaseLayout previewText={adminT.adminPreview} language="en" siteUrl={siteUrl}>
|
|
||||||
<Text style={styles.title}>{adminT.adminTitle}</Text>
|
|
||||||
<Text style={styles.greeting}>{adminT.adminGreeting}</Text>
|
|
||||||
<Text style={styles.text}>{adminT.adminMessage}</Text>
|
|
||||||
|
|
||||||
<Section style={styles.orderInfo}>
|
|
||||||
<Text style={styles.orderNumber}>
|
|
||||||
<strong>{adminT.orderNumber}:</strong> {orderNumber}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.customerInfo}>
|
|
||||||
<strong>{adminT.customerLabel}:</strong> {customerName}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.customerInfo}>
|
|
||||||
<strong>{adminT.customerEmailLabel}:</strong> {customerEmail}
|
|
||||||
</Text>
|
|
||||||
{phone && (
|
|
||||||
<Text style={styles.customerInfo}>
|
|
||||||
<strong>{adminT.phoneLabel}:</strong> {phone}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section style={styles.itemsSection}>
|
|
||||||
<Text style={styles.sectionTitle}>{adminT.items}</Text>
|
|
||||||
<Hr style={styles.hr} />
|
|
||||||
{items.map((item) => (
|
|
||||||
<Section key={item.id} style={styles.itemRow}>
|
|
||||||
<Text style={styles.itemName}>
|
|
||||||
{item.quantity}x {item.name}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
|
||||||
</Section>
|
|
||||||
))}
|
|
||||||
<Hr style={styles.hr} />
|
|
||||||
<Section style={styles.totalRow}>
|
|
||||||
<Text style={styles.totalLabel}>{adminT.total}:</Text>
|
|
||||||
<Text style={styles.totalValue}>{total}</Text>
|
|
||||||
</Section>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{shippingAddress && (
|
|
||||||
<Section style={styles.shippingSection}>
|
|
||||||
<Text style={styles.sectionTitle}>{adminT.shippingTo}</Text>
|
|
||||||
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{billingAddress && (
|
|
||||||
<Section style={styles.shippingSection}>
|
|
||||||
<Text style={styles.sectionTitle}>{adminT.billingAddressLabel}</Text>
|
|
||||||
<Text style={styles.shippingAddress}>{billingAddress}</Text>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Section style={styles.buttonSection}>
|
|
||||||
<Button href={`${dashboardUrl}/orders/${orderId}`} style={styles.button}>
|
|
||||||
{adminT.viewDashboard}
|
|
||||||
</Button>
|
|
||||||
</Section>
|
|
||||||
</BaseLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
|
||||||
<Text style={styles.title}>{t.title}</Text>
|
|
||||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
|
||||||
<Text style={styles.text}>{t.orderReceived}</Text>
|
|
||||||
|
|
||||||
<Section style={styles.orderInfo}>
|
|
||||||
<Text style={styles.orderNumber}>
|
|
||||||
<strong>{t.orderNumber}:</strong> {orderNumber}
|
|
||||||
</Text>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section style={styles.itemsSection}>
|
|
||||||
<Text style={styles.sectionTitle}>{t.items}</Text>
|
|
||||||
<Hr style={styles.hr} />
|
|
||||||
{items.map((item) => (
|
|
||||||
<Section key={item.id} style={styles.itemRow}>
|
|
||||||
<Text style={styles.itemName}>
|
|
||||||
{item.quantity}x {item.name}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
|
||||||
</Section>
|
|
||||||
))}
|
|
||||||
<Hr style={styles.hr} />
|
|
||||||
<Section style={styles.totalRow}>
|
|
||||||
<Text style={styles.totalLabel}>{t.total}:</Text>
|
|
||||||
<Text style={styles.totalValue}>{total}</Text>
|
|
||||||
</Section>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{shippingAddress && (
|
|
||||||
<Section style={styles.shippingSection}>
|
|
||||||
<Text style={styles.sectionTitle}>{t.shippingTo}</Text>
|
|
||||||
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Section style={styles.buttonSection}>
|
|
||||||
<Button href={siteUrl} style={styles.button}>
|
|
||||||
{language === "sr"
|
|
||||||
? "Pogledajte narudžbinu"
|
|
||||||
: language === "de"
|
|
||||||
? "Bestellung ansehen"
|
|
||||||
: language === "fr"
|
|
||||||
? "Voir la commande"
|
|
||||||
: "View Order"}
|
|
||||||
</Button>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Text style={styles.questions}>{t.questions}</Text>
|
|
||||||
<Text style={styles.thankYou}>{t.thankYou}</Text>
|
|
||||||
</BaseLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
title: {
|
|
||||||
fontSize: "24px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#1a1a1a",
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
greeting: {
|
|
||||||
fontSize: "16px",
|
|
||||||
color: "#333333",
|
|
||||||
marginBottom: "10px",
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#666666",
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
orderInfo: {
|
|
||||||
backgroundColor: "#f9f9f9",
|
|
||||||
padding: "15px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
orderNumber: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#333333",
|
|
||||||
margin: "0 0 8px 0",
|
|
||||||
},
|
|
||||||
customerInfo: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#333333",
|
|
||||||
margin: "0 0 4px 0",
|
|
||||||
},
|
|
||||||
itemsSection: {
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: "16px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#1a1a1a",
|
|
||||||
marginBottom: "10px",
|
|
||||||
},
|
|
||||||
hr: {
|
|
||||||
borderColor: "#e0e0e0",
|
|
||||||
margin: "10px 0",
|
|
||||||
},
|
|
||||||
itemRow: {
|
|
||||||
display: "flex" as const,
|
|
||||||
justifyContent: "space-between" as const,
|
|
||||||
padding: "8px 0",
|
|
||||||
},
|
|
||||||
itemName: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#333333",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
itemPrice: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#333333",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
totalRow: {
|
|
||||||
display: "flex" as const,
|
|
||||||
justifyContent: "space-between" as const,
|
|
||||||
padding: "8px 0",
|
|
||||||
},
|
|
||||||
totalLabel: {
|
|
||||||
fontSize: "16px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#1a1a1a",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
totalValue: {
|
|
||||||
fontSize: "16px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#1a1a1a",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
shippingSection: {
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
shippingAddress: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#666666",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
buttonSection: {
|
|
||||||
textAlign: "center" as const,
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
backgroundColor: "#000000",
|
|
||||||
color: "#ffffff",
|
|
||||||
padding: "12px 30px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
textDecoration: "none",
|
|
||||||
},
|
|
||||||
questions: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#666666",
|
|
||||||
marginBottom: "10px",
|
|
||||||
},
|
|
||||||
thankYou: {
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#1a1a1a",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
import { Button, Hr, Section, Text } from "@react-email/components";
|
|
||||||
import { BaseLayout } from "./BaseLayout";
|
|
||||||
|
|
||||||
interface OrderItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
quantity: number;
|
|
||||||
price: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderPaidProps {
|
|
||||||
language: string;
|
|
||||||
orderId: string;
|
|
||||||
orderNumber: string;
|
|
||||||
customerName: string;
|
|
||||||
items: OrderItem[];
|
|
||||||
total: string;
|
|
||||||
siteUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const translations: Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
title: string;
|
|
||||||
preview: string;
|
|
||||||
greeting: string;
|
|
||||||
orderPaid: string;
|
|
||||||
items: string;
|
|
||||||
total: string;
|
|
||||||
nextSteps: string;
|
|
||||||
nextStepsText: string;
|
|
||||||
questions: string;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
sr: {
|
|
||||||
title: "Plaćanje je primljeno!",
|
|
||||||
preview: "Vaša uplata je zabeležena",
|
|
||||||
greeting: "Poštovani {name},",
|
|
||||||
orderPaid:
|
|
||||||
"Plaćanje za vašu narudžbinu je primljeno. Hvala vam! Narudžbina će uskoro biti spremna za slanje.",
|
|
||||||
items: "Artikli",
|
|
||||||
total: "Ukupno",
|
|
||||||
nextSteps: "Šta dalje?",
|
|
||||||
nextStepsText:
|
|
||||||
"Primićete još jedan email kada vaša narudžbina bude poslata. Možete očekivati dostavu u roku od 3-5 radnih dana.",
|
|
||||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
title: "Payment Received!",
|
|
||||||
preview: "Your payment has been recorded",
|
|
||||||
greeting: "Dear {name},",
|
|
||||||
orderPaid:
|
|
||||||
"Payment for your order has been received. Thank you! Your order will be prepared for shipping soon.",
|
|
||||||
items: "Items",
|
|
||||||
total: "Total",
|
|
||||||
nextSteps: "What's next?",
|
|
||||||
nextStepsText:
|
|
||||||
"You will receive another email when your order ships. You can expect delivery within 3-5 business days.",
|
|
||||||
questions: "Questions? Email us at support@manoonoils.com",
|
|
||||||
},
|
|
||||||
de: {
|
|
||||||
title: "Zahlung erhalten!",
|
|
||||||
preview: "Ihre Zahlung wurde verbucht",
|
|
||||||
greeting: "Sehr geehrte/r {name},",
|
|
||||||
orderPaid:
|
|
||||||
"Zahlung für Ihre Bestellung ist eingegangen. Vielen Dank! Ihre Bestellung wird bald für den Versand vorbereitet.",
|
|
||||||
items: "Artikel",
|
|
||||||
total: "Gesamt",
|
|
||||||
nextSteps: "Was kommt als nächstes?",
|
|
||||||
nextStepsText:
|
|
||||||
"Sie erhalten eine weitere E-Mail, wenn Ihre Bestellung versandt wird. Die Lieferung erfolgt innerhalb von 3-5 Werktagen.",
|
|
||||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
|
||||||
},
|
|
||||||
fr: {
|
|
||||||
title: "Paiement reçu!",
|
|
||||||
preview: "Votre paiement a été enregistré",
|
|
||||||
greeting: "Cher(e) {name},",
|
|
||||||
orderPaid:
|
|
||||||
"Le paiement de votre commande a été reçu. Merci! Votre commande sera bientôt prête à être expédiée.",
|
|
||||||
items: "Articles",
|
|
||||||
total: "Total",
|
|
||||||
nextSteps: "Et ensuite?",
|
|
||||||
nextStepsText:
|
|
||||||
"Vous recevrez un autre email lorsque votre commande sera expédiée. Vous pouvez vous attendre à une livraison dans 3-5 jours ouvrables.",
|
|
||||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OrderPaid({
|
|
||||||
language = "en",
|
|
||||||
orderId,
|
|
||||||
orderNumber,
|
|
||||||
customerName,
|
|
||||||
items,
|
|
||||||
total,
|
|
||||||
siteUrl,
|
|
||||||
}: OrderPaidProps) {
|
|
||||||
const t = translations[language] || translations.en;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
|
||||||
<Text style={styles.title}>{t.title}</Text>
|
|
||||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
|
||||||
<Text style={styles.text}>{t.orderPaid}</Text>
|
|
||||||
|
|
||||||
<Section style={styles.orderInfo}>
|
|
||||||
<Text style={styles.orderNumber}>
|
|
||||||
<strong>Order Number:</strong> {orderNumber}
|
|
||||||
</Text>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section style={styles.itemsSection}>
|
|
||||||
<Text style={styles.sectionTitle}>{t.items}</Text>
|
|
||||||
<Hr style={styles.hr} />
|
|
||||||
{items.map((item) => (
|
|
||||||
<Section key={item.id} style={styles.itemRow}>
|
|
||||||
<Text style={styles.itemName}>
|
|
||||||
{item.quantity}x {item.name}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
|
||||||
</Section>
|
|
||||||
))}
|
|
||||||
<Hr style={styles.hr} />
|
|
||||||
<Section style={styles.totalRow}>
|
|
||||||
<Text style={styles.totalLabel}>{t.total}:</Text>
|
|
||||||
<Text style={styles.totalValue}>{total}</Text>
|
|
||||||
</Section>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section style={styles.nextSteps}>
|
|
||||||
<Text style={styles.nextStepsTitle}>{t.nextSteps}</Text>
|
|
||||||
<Text style={styles.nextStepsText}>{t.nextStepsText}</Text>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section style={styles.buttonSection}>
|
|
||||||
<Button href={siteUrl} style={styles.button}>
|
|
||||||
{language === "sr" ? "Nastavite kupovinu" : "Continue Shopping"}
|
|
||||||
</Button>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Text style={styles.questions}>{t.questions}</Text>
|
|
||||||
</BaseLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
title: {
|
|
||||||
fontSize: "24px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#16a34a",
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
greeting: {
|
|
||||||
fontSize: "16px",
|
|
||||||
color: "#333333",
|
|
||||||
marginBottom: "10px",
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#666666",
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
orderInfo: {
|
|
||||||
backgroundColor: "#f0fdf4",
|
|
||||||
padding: "15px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
orderNumber: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#333333",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
itemsSection: {
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: "16px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#1a1a1a",
|
|
||||||
marginBottom: "10px",
|
|
||||||
},
|
|
||||||
hr: {
|
|
||||||
borderColor: "#e0e0e0",
|
|
||||||
margin: "10px 0",
|
|
||||||
},
|
|
||||||
itemRow: {
|
|
||||||
display: "flex" as const,
|
|
||||||
justifyContent: "space-between" as const,
|
|
||||||
padding: "8px 0",
|
|
||||||
},
|
|
||||||
itemName: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#333333",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
itemPrice: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#333333",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
totalRow: {
|
|
||||||
display: "flex" as const,
|
|
||||||
justifyContent: "space-between" as const,
|
|
||||||
padding: "8px 0",
|
|
||||||
},
|
|
||||||
totalLabel: {
|
|
||||||
fontSize: "16px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#1a1a1a",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
totalValue: {
|
|
||||||
fontSize: "16px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#1a1a1a",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
nextSteps: {
|
|
||||||
backgroundColor: "#f9f9f9",
|
|
||||||
padding: "15px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
nextStepsTitle: {
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#1a1a1a",
|
|
||||||
marginBottom: "5px",
|
|
||||||
},
|
|
||||||
nextStepsText: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#666666",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
buttonSection: {
|
|
||||||
textAlign: "center" as const,
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
backgroundColor: "#000000",
|
|
||||||
color: "#ffffff",
|
|
||||||
padding: "12px 30px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
textDecoration: "none",
|
|
||||||
},
|
|
||||||
questions: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#666666",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { Button, Hr, Section, Text } from "@react-email/components";
|
|
||||||
import { BaseLayout } from "./BaseLayout";
|
|
||||||
|
|
||||||
interface OrderItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
quantity: number;
|
|
||||||
price: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderShippedProps {
|
|
||||||
language: string;
|
|
||||||
orderId: string;
|
|
||||||
orderNumber: string;
|
|
||||||
customerName: string;
|
|
||||||
items: OrderItem[];
|
|
||||||
trackingNumber?: string;
|
|
||||||
trackingUrl?: string;
|
|
||||||
siteUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const translations: Record<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
title: string;
|
|
||||||
preview: string;
|
|
||||||
greeting: string;
|
|
||||||
orderShipped: string;
|
|
||||||
tracking: string;
|
|
||||||
items: string;
|
|
||||||
questions: string;
|
|
||||||
}
|
|
||||||
> = {
|
|
||||||
sr: {
|
|
||||||
title: "Vaša narudžbina je poslata!",
|
|
||||||
preview: "Vaša narudžbina je na putu",
|
|
||||||
greeting: "Poštovani {name},",
|
|
||||||
orderShipped:
|
|
||||||
"Odlične vesti! Vaša narudžbina je poslata i uskoro će stići na vašu adresu.",
|
|
||||||
tracking: "Praćenje pošiljke",
|
|
||||||
items: "Artikli",
|
|
||||||
questions: "Imate pitanja? Pišite nam na support@manoonoils.com",
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
title: "Your Order Has Shipped!",
|
|
||||||
preview: "Your order is on its way",
|
|
||||||
greeting: "Dear {name},",
|
|
||||||
orderShipped:
|
|
||||||
"Great news! Your order has been shipped and will arrive at your address soon.",
|
|
||||||
tracking: "Track your shipment",
|
|
||||||
items: "Items",
|
|
||||||
questions: "Questions? Email us at support@manoonoils.com",
|
|
||||||
},
|
|
||||||
de: {
|
|
||||||
title: "Ihre Bestellung wurde versendet!",
|
|
||||||
preview: "Ihre Bestellung ist unterwegs",
|
|
||||||
greeting: "Sehr geehrte/r {name},",
|
|
||||||
orderShipped:
|
|
||||||
"Großartige Neuigkeiten! Ihre Bestellung wurde versandt und wird in Kürze bei Ihnen eintreffen.",
|
|
||||||
tracking: "Sendung verfolgen",
|
|
||||||
items: "Artikel",
|
|
||||||
questions: "Fragen? Schreiben Sie uns an support@manoonoils.com",
|
|
||||||
},
|
|
||||||
fr: {
|
|
||||||
title: "Votre commande a été expédiée!",
|
|
||||||
preview: "Votre commande est en route",
|
|
||||||
greeting: "Cher(e) {name},",
|
|
||||||
orderShipped:
|
|
||||||
"Bonne nouvelle! Votre commande a été expédiée et arrivera bientôt à votre adresse.",
|
|
||||||
tracking: "Suivre votre envoi",
|
|
||||||
items: "Articles",
|
|
||||||
questions: "Questions? Écrivez-nous à support@manoonoils.com",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OrderShipped({
|
|
||||||
language = "en",
|
|
||||||
orderId,
|
|
||||||
orderNumber,
|
|
||||||
customerName,
|
|
||||||
items,
|
|
||||||
trackingNumber,
|
|
||||||
trackingUrl,
|
|
||||||
siteUrl,
|
|
||||||
}: OrderShippedProps) {
|
|
||||||
const t = translations[language] || translations.en;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
|
|
||||||
<Text style={styles.title}>{t.title}</Text>
|
|
||||||
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
|
|
||||||
<Text style={styles.text}>{t.orderShipped}</Text>
|
|
||||||
|
|
||||||
{trackingNumber && (
|
|
||||||
<Section style={styles.trackingSection}>
|
|
||||||
<Text style={styles.sectionTitle}>{t.tracking}</Text>
|
|
||||||
{trackingUrl ? (
|
|
||||||
<Button href={trackingUrl} style={styles.trackingButton}>
|
|
||||||
{trackingNumber}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Text style={styles.trackingNumber}>{trackingNumber}</Text>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Section style={styles.itemsSection}>
|
|
||||||
<Text style={styles.sectionTitle}>{t.items}</Text>
|
|
||||||
<Hr style={styles.hr} />
|
|
||||||
{items.map((item) => (
|
|
||||||
<Section key={item.id} style={styles.itemRow}>
|
|
||||||
<Text style={styles.itemName}>
|
|
||||||
{item.quantity}x {item.name}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.itemPrice}>{item.price}</Text>
|
|
||||||
</Section>
|
|
||||||
))}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Text style={styles.questions}>{t.questions}</Text>
|
|
||||||
</BaseLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const styles = {
|
|
||||||
title: {
|
|
||||||
fontSize: "24px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#1a1a1a",
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
greeting: {
|
|
||||||
fontSize: "16px",
|
|
||||||
color: "#333333",
|
|
||||||
marginBottom: "10px",
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#666666",
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
trackingSection: {
|
|
||||||
backgroundColor: "#f9f9f9",
|
|
||||||
padding: "15px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: "16px",
|
|
||||||
fontWeight: "bold" as const,
|
|
||||||
color: "#1a1a1a",
|
|
||||||
marginBottom: "10px",
|
|
||||||
},
|
|
||||||
trackingNumber: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#333333",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
trackingButton: {
|
|
||||||
backgroundColor: "#000000",
|
|
||||||
color: "#ffffff",
|
|
||||||
padding: "10px 20px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontSize: "14px",
|
|
||||||
textDecoration: "none",
|
|
||||||
},
|
|
||||||
itemsSection: {
|
|
||||||
marginBottom: "20px",
|
|
||||||
},
|
|
||||||
hr: {
|
|
||||||
borderColor: "#e0e0e0",
|
|
||||||
margin: "10px 0",
|
|
||||||
},
|
|
||||||
itemRow: {
|
|
||||||
display: "flex" as const,
|
|
||||||
justifyContent: "space-between" as const,
|
|
||||||
padding: "8px 0",
|
|
||||||
},
|
|
||||||
itemName: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#333333",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
itemPrice: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#333333",
|
|
||||||
margin: "0",
|
|
||||||
},
|
|
||||||
questions: {
|
|
||||||
fontSize: "14px",
|
|
||||||
color: "#666666",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export { BaseLayout } from "./BaseLayout";
|
|
||||||
export { OrderConfirmation } from "./OrderConfirmation";
|
|
||||||
export { OrderShipped } from "./OrderShipped";
|
|
||||||
export { OrderCancelled } from "./OrderCancelled";
|
|
||||||
export { OrderPaid } from "./OrderPaid";
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
import { sendEmailToCustomer, sendEmailToAdmin } from "@/lib/resend";
|
|
||||||
import { OrderConfirmation } from "@/emails/OrderConfirmation";
|
|
||||||
import { OrderShipped } from "@/emails/OrderShipped";
|
|
||||||
import { OrderCancelled } from "@/emails/OrderCancelled";
|
|
||||||
import { OrderPaid } from "@/emails/OrderPaid";
|
|
||||||
import { formatPrice } from "@/app/api/webhooks/saleor/utils";
|
|
||||||
|
|
||||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://dev.manoonoils.com";
|
|
||||||
const DASHBOARD_URL = process.env.DASHBOARD_URL || "https://dashboard.manoonoils.com";
|
|
||||||
|
|
||||||
// Translation helper for email subjects
|
|
||||||
function getOrderConfirmationSubject(language: string, orderNumber: string): string {
|
|
||||||
const subjects: Record<string, string> = {
|
|
||||||
sr: `Potvrda narudžbine #${orderNumber}`,
|
|
||||||
de: `Bestellbestätigung #${orderNumber}`,
|
|
||||||
fr: `Confirmation de commande #${orderNumber}`,
|
|
||||||
en: `Order Confirmation #${orderNumber}`,
|
|
||||||
};
|
|
||||||
return subjects[language] || subjects.en;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrderShippedSubject(language: string, orderNumber: string): string {
|
|
||||||
const subjects: Record<string, string> = {
|
|
||||||
sr: `Vaša narudžbina #${orderNumber} je poslata!`,
|
|
||||||
de: `Ihre Bestellung #${orderNumber} wurde versendet!`,
|
|
||||||
fr: `Votre commande #${orderNumber} a été expédiée!`,
|
|
||||||
en: `Your Order #${orderNumber} Has Shipped!`,
|
|
||||||
};
|
|
||||||
return subjects[language] || subjects.en;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrderCancelledSubject(language: string, orderNumber: string): string {
|
|
||||||
const subjects: Record<string, string> = {
|
|
||||||
sr: `Vaša narudžbina #${orderNumber} je otkazana`,
|
|
||||||
de: `Ihre Bestellung #${orderNumber} wurde storniert`,
|
|
||||||
fr: `Votre commande #${orderNumber} a été annulée`,
|
|
||||||
en: `Your Order #${orderNumber} Has Been Cancelled`,
|
|
||||||
};
|
|
||||||
return subjects[language] || subjects.en;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrderPaidSubject(language: string, orderNumber: string): string {
|
|
||||||
const subjects: Record<string, string> = {
|
|
||||||
sr: `Plaćanje za narudžbinu #${orderNumber} je primljeno!`,
|
|
||||||
de: `Zahlung für Bestellung #${orderNumber} erhalten!`,
|
|
||||||
fr: `Paiement reçu pour la commande #${orderNumber}!`,
|
|
||||||
en: `Payment Received for Order #${orderNumber}!`,
|
|
||||||
};
|
|
||||||
return subjects[language] || subjects.en;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interfaces
|
|
||||||
interface OrderItem {
|
|
||||||
id: string;
|
|
||||||
productName: string;
|
|
||||||
variantName?: string;
|
|
||||||
quantity: number;
|
|
||||||
totalPrice: {
|
|
||||||
gross: {
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderAddress {
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
streetAddress1?: string;
|
|
||||||
streetAddress2?: string;
|
|
||||||
city?: string;
|
|
||||||
postalCode?: string;
|
|
||||||
country?: string;
|
|
||||||
phone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Order {
|
|
||||||
id: string;
|
|
||||||
number: string;
|
|
||||||
userEmail: string;
|
|
||||||
user?: {
|
|
||||||
firstName?: string;
|
|
||||||
lastName?: string;
|
|
||||||
};
|
|
||||||
billingAddress?: OrderAddress;
|
|
||||||
shippingAddress?: OrderAddress;
|
|
||||||
lines: OrderItem[];
|
|
||||||
total: {
|
|
||||||
gross: {
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
languageCode?: string;
|
|
||||||
metadata?: Array<{ key: string; value: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OrderEmailItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
quantity: number;
|
|
||||||
price: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
class OrderNotificationService {
|
|
||||||
private static instance: OrderNotificationService;
|
|
||||||
|
|
||||||
static getInstance(): OrderNotificationService {
|
|
||||||
if (!OrderNotificationService.instance) {
|
|
||||||
OrderNotificationService.instance = new OrderNotificationService();
|
|
||||||
}
|
|
||||||
return OrderNotificationService.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseOrderItems(lines: OrderItem[], currency: string): OrderEmailItem[] {
|
|
||||||
return lines.map((line) => ({
|
|
||||||
id: line.id,
|
|
||||||
name: line.variantName ? `${line.productName} (${line.variantName})` : line.productName,
|
|
||||||
quantity: line.quantity,
|
|
||||||
price: formatPrice(line.totalPrice.gross.amount, currency),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatAddress(address?: OrderAddress): string {
|
|
||||||
if (!address) return "";
|
|
||||||
const parts = [
|
|
||||||
address.firstName,
|
|
||||||
address.lastName,
|
|
||||||
address.streetAddress1,
|
|
||||||
address.streetAddress2,
|
|
||||||
address.city,
|
|
||||||
address.postalCode,
|
|
||||||
address.country,
|
|
||||||
].filter(Boolean);
|
|
||||||
return parts.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCustomerName(order: Order): string {
|
|
||||||
if (order.user?.firstName || order.user?.lastName) {
|
|
||||||
return `${order.user.firstName || ""} ${order.user.lastName || ""}`.trim();
|
|
||||||
}
|
|
||||||
if (order.shippingAddress?.firstName || order.shippingAddress?.lastName) {
|
|
||||||
return `${order.shippingAddress.firstName || ""} ${order.shippingAddress.lastName || ""}`.trim();
|
|
||||||
}
|
|
||||||
return "Customer";
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCustomerLanguage(order: Order): string {
|
|
||||||
const LANGUAGE_CODE_MAP: Record<string, string> = {
|
|
||||||
SR: "sr",
|
|
||||||
EN: "en",
|
|
||||||
DE: "de",
|
|
||||||
FR: "fr",
|
|
||||||
};
|
|
||||||
|
|
||||||
if (order.languageCode && LANGUAGE_CODE_MAP[order.languageCode]) {
|
|
||||||
return LANGUAGE_CODE_MAP[order.languageCode];
|
|
||||||
}
|
|
||||||
if (order.metadata) {
|
|
||||||
const langMeta = order.metadata.find((m) => m.key === "language");
|
|
||||||
if (langMeta && LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()]) {
|
|
||||||
return LANGUAGE_CODE_MAP[langMeta.value.toUpperCase()];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "en";
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendOrderConfirmation(order: Order): Promise<void> {
|
|
||||||
const language = this.getCustomerLanguage(order);
|
|
||||||
const currency = order.total.gross.currency;
|
|
||||||
const customerName = this.getCustomerName(order);
|
|
||||||
const customerEmail = order.userEmail;
|
|
||||||
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
|
|
||||||
|
|
||||||
await sendEmailToCustomer({
|
|
||||||
to: customerEmail,
|
|
||||||
subject: getOrderConfirmationSubject(language, order.number),
|
|
||||||
react: OrderConfirmation({
|
|
||||||
language,
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerEmail,
|
|
||||||
customerName,
|
|
||||||
items: this.parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
shippingAddress: this.formatAddress(order.shippingAddress),
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
language,
|
|
||||||
idempotencyKey: `order-confirmed/${order.id}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendOrderConfirmationToAdmin(order: Order): Promise<void> {
|
|
||||||
const currency = order.total.gross.currency;
|
|
||||||
const customerName = this.getCustomerName(order);
|
|
||||||
const customerEmail = order.userEmail;
|
|
||||||
const phone = order.shippingAddress?.phone || order.billingAddress?.phone;
|
|
||||||
|
|
||||||
await sendEmailToAdmin({
|
|
||||||
subject: `🎉 New Order #${order.number} - ${formatPrice(order.total.gross.amount, currency)}`,
|
|
||||||
react: OrderConfirmation({
|
|
||||||
language: "en",
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerEmail,
|
|
||||||
customerName,
|
|
||||||
items: this.parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
shippingAddress: this.formatAddress(order.shippingAddress),
|
|
||||||
billingAddress: this.formatAddress(order.billingAddress),
|
|
||||||
phone,
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
dashboardUrl: DASHBOARD_URL,
|
|
||||||
isAdmin: true,
|
|
||||||
}),
|
|
||||||
eventType: "ORDER_CONFIRMED",
|
|
||||||
orderId: order.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendOrderShipped(order: Order, trackingNumber?: string, trackingUrl?: string): Promise<void> {
|
|
||||||
const language = this.getCustomerLanguage(order);
|
|
||||||
const currency = order.total.gross.currency;
|
|
||||||
const customerName = this.getCustomerName(order);
|
|
||||||
const customerEmail = order.userEmail;
|
|
||||||
|
|
||||||
await sendEmailToCustomer({
|
|
||||||
to: customerEmail,
|
|
||||||
subject: getOrderShippedSubject(language, order.number),
|
|
||||||
react: OrderShipped({
|
|
||||||
language,
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: this.parseOrderItems(order.lines, currency),
|
|
||||||
trackingNumber,
|
|
||||||
trackingUrl,
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
language,
|
|
||||||
idempotencyKey: `order-fulfilled/${order.id}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendOrderShippedToAdmin(order: Order, trackingNumber?: string, trackingUrl?: string): Promise<void> {
|
|
||||||
const currency = order.total.gross.currency;
|
|
||||||
const customerName = this.getCustomerName(order);
|
|
||||||
|
|
||||||
await sendEmailToAdmin({
|
|
||||||
subject: `Order Shipped #${order.number} - ${customerName}`,
|
|
||||||
react: OrderShipped({
|
|
||||||
language: "en",
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: this.parseOrderItems(order.lines, currency),
|
|
||||||
trackingNumber,
|
|
||||||
trackingUrl,
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
eventType: "ORDER_FULFILLED",
|
|
||||||
orderId: order.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendOrderCancelled(order: Order, reason?: string): Promise<void> {
|
|
||||||
const language = this.getCustomerLanguage(order);
|
|
||||||
const currency = order.total.gross.currency;
|
|
||||||
const customerName = this.getCustomerName(order);
|
|
||||||
const customerEmail = order.userEmail;
|
|
||||||
|
|
||||||
await sendEmailToCustomer({
|
|
||||||
to: customerEmail,
|
|
||||||
subject: getOrderCancelledSubject(language, order.number),
|
|
||||||
react: OrderCancelled({
|
|
||||||
language,
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: this.parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
reason,
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
language,
|
|
||||||
idempotencyKey: `order-cancelled/${order.id}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendOrderCancelledToAdmin(order: Order, reason?: string): Promise<void> {
|
|
||||||
const currency = order.total.gross.currency;
|
|
||||||
const customerName = this.getCustomerName(order);
|
|
||||||
|
|
||||||
await sendEmailToAdmin({
|
|
||||||
subject: `Order Cancelled #${order.number} - ${customerName}`,
|
|
||||||
react: OrderCancelled({
|
|
||||||
language: "en",
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: this.parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
reason,
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
eventType: "ORDER_CANCELLED",
|
|
||||||
orderId: order.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendOrderPaid(order: Order): Promise<void> {
|
|
||||||
const language = this.getCustomerLanguage(order);
|
|
||||||
const currency = order.total.gross.currency;
|
|
||||||
const customerName = this.getCustomerName(order);
|
|
||||||
const customerEmail = order.userEmail;
|
|
||||||
|
|
||||||
await sendEmailToCustomer({
|
|
||||||
to: customerEmail,
|
|
||||||
subject: getOrderPaidSubject(language, order.number),
|
|
||||||
react: OrderPaid({
|
|
||||||
language,
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: this.parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
language,
|
|
||||||
idempotencyKey: `order-paid/${order.id}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendOrderPaidToAdmin(order: Order): Promise<void> {
|
|
||||||
const currency = order.total.gross.currency;
|
|
||||||
const customerName = this.getCustomerName(order);
|
|
||||||
|
|
||||||
await sendEmailToAdmin({
|
|
||||||
subject: `Payment Received #${order.number} - ${customerName} - ${formatPrice(order.total.gross.amount, currency)}`,
|
|
||||||
react: OrderPaid({
|
|
||||||
language: "en",
|
|
||||||
orderId: order.id,
|
|
||||||
orderNumber: order.number,
|
|
||||||
customerName,
|
|
||||||
items: this.parseOrderItems(order.lines, currency),
|
|
||||||
total: formatPrice(order.total.gross.amount, currency),
|
|
||||||
siteUrl: SITE_URL,
|
|
||||||
}),
|
|
||||||
eventType: "ORDER_FULLY_PAID",
|
|
||||||
orderId: order.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const orderNotificationService = OrderNotificationService.getInstance();
|
|
||||||
export default orderNotificationService;
|
|
||||||
Reference in New Issue
Block a user