Compare commits

..
Author SHA1 Message Date
Unchained 6f9081cb52 fix: remove turbopack.root config causing CPU issues and module resolution errors
Build and Deploy / build (push) Has been cancelled
2026-03-28 19:32:16 +02:00
Unchained 7f35dc57c6 fix: set turbopack root directory 2026-03-28 18:48:58 +02:00
Unchained 7d63f4fbcd fix: add missing useRef import 2026-03-28 18:44:07 +02:00
Unchained b78b081d29 fix: use ref instead of state for initialization flag to prevent dependency array size change 2026-03-28 18:41:36 +02:00
Unchained 676dda4642 fix: set language code in CartDrawer before init 2026-03-28 18:20:12 +02:00
Unchained c8d184f9dc fix: set language code before initializing checkout 2026-03-28 18:18:52 +02:00
Unchained 322c4c805b fix: add back email update step in checkout 2026-03-28 18:09:57 +02:00
Unchained bcf74e1fd1 feat: implement one-page checkout with dynamic shipping 2026-03-28 18:03:12 +02:00
Unchained 7ca756fc5a docs: add one-page checkout implementation plan 2026-03-28 17:51:06 +02:00
Unchained ca363a2406 fix: set language code before add to cart
Build and Deploy / build (push) Has been cancelled
Ensure language code is set in checkout store before creating checkout.
This ensures orders created from any page will have correct language.

- Add setLanguageCode to NewHero before addLine
- Add setLanguageCode to ProductDetail before addLine
- Uses current locale from useLocale or props
2026-03-28 12:53:14 +02:00
Unchained 5ec0e6c92c feat: set checkout languageCode based on user locale
Build and Deploy / build (push) Has been cancelled
- Add languageCode to checkout store state
- Add setLanguageCode action to store
- Pass languageCode when creating checkout
- Header component sets language code from useLocale hook
- Enables multi-language order confirmation emails
2026-03-28 12:31:34 +02:00
Unchained ee574cb736 Merge dev into master
Build and Deploy / build (push) Has been cancelled
Includes:
- feat: store userLanguage in checkout metadata for multi-language emails
- refactor: Remove email functionality - migrated to core-extensions app
- docs: Add comprehensive feature roadmap with 20 optimization features
2026-03-28 09:59:17 +02:00
Unchained a419337d99 feat: store userLanguage in checkout metadata for multi-language emails
Adds userLanguage and userLocale to checkout metadata during order completion.
This allows N8N workflows to detect the customer's selected language and
send order confirmation emails in the correct language (sr, en, de, fr).
2026-03-28 07:27:09 +02:00
Unchained 09294fd752 refactor: Remove email functionality - migrated to core-extensions app
Removed:
- Webhook handlers (src/app/api/webhooks/saleor/)
- Email templates (src/emails/)
- OrderNotificationService (src/lib/services/)

Emails now handled by saleor-core-extensions service
Manifest: https://core-extensions.manoonoils.com/api/manifest
2026-03-26 08:50:58 +02:00
Unchained f66f9b87ab docs: Add comprehensive feature roadmap with 20 optimization features
- Organized into 7 implementation phases with dependencies
- Includes priority matrix (P0/P1/P2)
- Revenue and SEO impact ratings
- Success metrics for tracking
- Resource requirements and timeline estimates
- Dependency graph showing implementation order
2026-03-25 21:54:38 +02:00
20 changed files with 562 additions and 1958 deletions
+367
View File
@@ -0,0 +1,367 @@
# One-Page Checkout Implementation Plan
**Branch:** `feature/one-page-checkout`
**Status:** In Development
**Priority:** High
**Phone Requirement:** Required (not optional)
---
## Overview
Convert the current two-phase checkout into a streamlined one-page checkout experience where customers can see all fields at once and complete their order in a single action.
### Current State
- **Phase 1:** Collect email, shipping address → fetch shipping methods
- **Phase 2:** Select shipping method, billing address → complete order
- **Total API calls:** 6-7 sequential requests across 2 user interactions
### Target State
- **Single Page:** All fields visible simultaneously
- **Dynamic updates:** Shipping methods fetch automatically when address changes
- **Single submit:** One "Complete Order" button
- **Optimized API:** 3-4 sequential steps (parallel where possible)
---
## Requirements
### Must-Have
- [ ] All checkout fields visible on single page
- [ ] Phone number is **required** (strict validation)
- [ ] Shipping methods fetch automatically (debounced) when address changes
- [ ] Real-time total calculation (updates when shipping method selected)
- [ ] Single "Complete Order" submit button
- [ ] Section-based validation with inline errors
- [ ] Auto-scroll to first error on validation failure
- [ ] Preserve form data on error
### UX Requirements
- [ ] Clear visual hierarchy (Contact → Shipping → Billing → Shipping Method → Payment)
- [ ] Collapsible sections (optional - all expanded by default)
- [ ] Loading states for shipping method fetching
- [ ] Disabled submit button until all required fields valid
- [ ] Success confirmation page (existing)
### Technical Requirements
- [ ] Debounced shipping method API calls (500ms)
- [ ] Optimistic UI updates where possible
- [ ] Proper error handling per section
- [ ] Analytics events for checkout steps
- [ ] Mobile-responsive layout
---
## UI Layout
### Left Column (Form - 60% width on desktop)
```
┌─────────────────────────────────────┐
│ 1. Contact Information │
│ ├─ Email * [________________] │
│ └─ Phone * [________________] │
│ [+381... format hint] │
├─────────────────────────────────────┤
│ 2. Shipping Address │
│ ├─ First Name * [____________] │
│ ├─ Last Name * [_____________] │
│ ├─ Country * [▼ Serbia ▼] │
│ ├─ Street Address * [________] │
│ ├─ Apt/Suite [______________] │
│ ├─ City * [_________________] │
│ └─ Postal Code * [__________] │
├─────────────────────────────────────┤
│ 3. Billing Address │
│ [✓] Same as shipping address │
│ (Fields hidden when checked) │
├─────────────────────────────────────┤
│ 4. Shipping Method │
│ (Loading... / Select to see │
│ available options) │
│ ○ Standard (2-3 days) 400 RSD │
│ ○ Express (1-2 days) 800 RSD │
├─────────────────────────────────────┤
│ 5. Payment Method │
│ ● Cash on Delivery │
│ (Additional payment methods TBD) │
├─────────────────────────────────────┤
│ [ Complete Order - 3,600 RSD ] │
│ Loading spinner when processing │
└─────────────────────────────────────┘
```
### Right Column (Order Summary - 40% width on desktop)
```
┌─────────────────────────────────────┐
│ Order Summary │
├─────────────────────────────────────┤
│ Product Image Serum x1 3,200 │
│ RSD │
├─────────────────────────────────────┤
│ Subtotal 3,200 RSD │
│ Shipping 400 RSD │
│ ───────────────────────────────── │
│ Total 3,600 RSD │
└─────────────────────────────────────┘
```
### Mobile Layout
Single column, stacked sections with sticky order summary at bottom.
---
## Technical Implementation
### State Management
```typescript
// Form state (existing)
const [shippingAddress, setShippingAddress] = useState<AddressForm>({...});
const [billingAddress, setBillingAddress] = useState<AddressForm>({...});
const [sameAsShipping, setSameAsShipping] = useState(true);
// New state
const [paymentMethod, setPaymentMethod] = useState<string>("cod");
const [errors, setErrors] = useState<ValidationErrors>({
contact: null,
shipping: null,
billing: null,
shippingMethod: null,
general: null,
});
```
### Debounced Shipping Method Fetching
```typescript
useEffect(() => {
if (!isAddressComplete(shippingAddress)) return;
const timer = setTimeout(() => {
fetchShippingMethods();
}, 500); // 500ms debounce
return () => clearTimeout(timer);
}, [shippingAddress]);
```
### Validation Schema
```typescript
const validationRules = {
email: (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value),
phone: (value) => {
// Country-specific validation
// Serbia: +381 XX XXX XXXX
// Bosnia: +387 XX XXX XXX
// etc.
},
required: (value) => value.trim().length > 0,
postalCode: (value, country) => {
// Country-specific postal code validation
},
};
```
### API Call Sequence
**Optimized Flow (parallel + sequential):**
```
Step 1: Validation (client-side)
├─ Validate all fields
└─ Show inline errors
Step 2: Parallel Independent Calls
├─ Update Email
└─ Update Shipping Address
(Both can run simultaneously)
Step 3: Conditional Call
└─ Update Billing Address (if different from shipping)
Step 4: Sequential Dependent Calls
├─ Update Shipping Method
├─ Update Metadata (phone, language, payment method)
└─ Complete Checkout
Total: 4 sequential steps vs current 7+
```
### Error Handling Strategy
**Field-level:**
- Real-time validation on blur
- Visual indicators (red border, error message)
- Prevent submit if validation fails
**Section-level:**
- Group errors by section
- Show section header in red if has errors
- Expand section if collapsed and has errors
**Form-level:**
- On submit: validate all fields
- If errors: scroll to first error, show summary
- If API error: show in relevant section, preserve data
**API-level:**
- Map Saleor errors to form fields when possible
- Generic error: show at top of form
- Network error: show retry button
---
## Files to Modify
### Primary Files
1. **`/src/app/[locale]/checkout/page.tsx`**
- Major refactor of checkout flow
- Combine Phase 1 & Phase 2 into single component
- Add debounced shipping method fetching
- Implement section-based validation
- Optimize API call sequence
2. **`/src/lib/saleor/mutations/Checkout.ts`**
- Ensure all mutations available
- Add metadata update mutation if needed
3. **`/src/lib/saleor/queries/Checkout.ts`**
- Ensure checkout query returns shipping methods
### Translation Files
4. **`/messages/sr.json`** (and other language files)
- Add new translation keys for one-page checkout
- Section headers
- Validation messages
- Button labels
### Styling
5. **`/src/app/globals.css`** (or Tailwind config)
- Ensure consistent form styling
- Add validation state styles
- Loading spinner styles
---
## Implementation Phases
### Phase 1: Core Structure (Day 1-2)
- [ ] Refactor checkout page layout
- [ ] Display all sections simultaneously
- [ ] Keep existing form logic working
- [ ] Test existing flow still works
### Phase 2: Dynamic Shipping Methods (Day 3)
- [ ] Implement debounced fetching
- [ ] Add loading states
- [ ] Display shipping methods inline
- [ ] Update total when method selected
### Phase 3: Validation & Error Handling (Day 4)
- [ ] Implement field-level validation
- [ ] Add section-based error display
- [ ] Auto-scroll to errors
- [ ] Test all validation scenarios
### Phase 4: Optimization (Day 5)
- [ ] Optimize API call sequence
- [ ] Add parallel mutation execution
- [ ] Improve loading states
- [ ] Add optimistic updates
### Phase 5: Polish (Day 6)
- [ ] Mobile responsiveness
- [ ] Analytics events
- [ ] Accessibility improvements
- [ ] Final testing
---
## Testing Checklist
### Functionality Tests
- [ ] Fill all fields, submit successfully
- [ ] Verify order created in Saleor
- [ ] Verify emails sent
- [ ] Change shipping method, verify total updates
- [ ] Change address, verify shipping methods refetch
### Validation Tests
- [ ] Submit with empty email → email error
- [ ] Submit with empty phone → phone error
- [ ] Submit with invalid email format → format error
- [ ] Submit with invalid phone → format error
- [ ] Submit with empty required fields → field errors
- [ ] Submit without selecting shipping method → shipping error
### Edge Cases
- [ ] Slow network (test debouncing)
- [ ] No shipping methods available
- [ ] API failure during submission
- [ ] Partial API failure (some mutations succeed)
- [ ] Browser refresh (preserve data?)
### Mobile Tests
- [ ] Layout works on iPhone SE
- [ ] Layout works on iPhone 14 Pro Max
- [ ] Touch targets large enough
- [ ] Scroll behavior smooth
### Accessibility Tests
- [ ] Tab navigation works
- [ ] Screen reader friendly
- [ ] Error announcements
- [ ] Focus management
---
## Rollout Strategy
1. **Development:** Complete on feature branch
2. **Testing:** Local testing with all scenarios
3. **Staging:** Deploy to dev.manoonoils.com
4. **Monitoring:** Check for errors, conversion rates
5. **Production:** Merge to master and deploy
---
## Success Metrics
- **Conversion Rate:** Should increase (fewer steps = less drop-off)
- **Time to Complete:** Should decrease (single page vs two phases)
- **Error Rate:** Should decrease (better validation)
- **Mobile Completion:** Should improve (optimized for mobile)
---
## Future Enhancements (Out of Scope)
- [ ] Save addresses for logged-in users
- [ ] Address autocomplete (Google Maps)
- [ ] Multiple payment methods (Stripe, etc.)
- [ ] Guest checkout improvements
- [ ] Order notes/comments field
- [ ] Gift wrapping options
- [ ] Promo code input
---
## Notes
- Phone number is **strictly required** - validate format per country
- Keep existing checkout success page
- Maintain multi-language support
- Ensure analytics tracking works
- Don't break existing cart functionality
---
**Created:** March 28, 2026
**Branch:** feature/one-page-checkout
**Next Step:** Start Phase 1 - Core Structure
+135 -100
View File
@@ -129,11 +129,78 @@ export default function CheckoutPage() {
const [shippingMethods, setShippingMethods] = useState<ShippingMethod[]>([]);
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
const [showShippingMethods, setShowShippingMethods] = useState(false);
const [isLoadingShipping, setIsLoadingShipping] = useState(false);
const lines = getLines();
const total = getTotal();
// Debounced shipping method fetching
useEffect(() => {
if (!checkout) return;
// Check if address is complete enough to fetch shipping methods
const isAddressComplete =
shippingAddress.firstName &&
shippingAddress.lastName &&
shippingAddress.streetAddress1 &&
shippingAddress.city &&
shippingAddress.postalCode &&
shippingAddress.country;
if (!isAddressComplete) {
setShippingMethods([]);
return;
}
const timer = setTimeout(async () => {
setIsLoadingShipping(true);
try {
console.log("Fetching shipping methods...");
// First update the shipping address
await saleorClient.mutate<ShippingAddressUpdateResponse>({
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
variables: {
checkoutId: checkout.id,
shippingAddress: {
firstName: shippingAddress.firstName,
lastName: shippingAddress.lastName,
streetAddress1: shippingAddress.streetAddress1,
streetAddress2: shippingAddress.streetAddress2,
city: shippingAddress.city,
postalCode: shippingAddress.postalCode,
country: shippingAddress.country,
phone: shippingAddress.phone,
},
},
});
// Then query for shipping methods
const checkoutQueryResult = await saleorClient.query<CheckoutQueryResponse>({
query: GET_CHECKOUT_BY_ID,
variables: { id: checkout.id },
fetchPolicy: "network-only",
});
const availableMethods = checkoutQueryResult.data?.checkout?.shippingMethods || [];
console.log("Available shipping methods:", availableMethods);
setShippingMethods(availableMethods);
// Auto-select first method if none selected
if (availableMethods.length > 0 && !selectedShippingMethod) {
setSelectedShippingMethod(availableMethods[0].id);
}
} catch (err) {
console.error("Error fetching shipping methods:", err);
} finally {
setIsLoadingShipping(false);
}
}, 500); // 500ms debounce
return () => clearTimeout(timer);
}, [checkout, shippingAddress]);
useEffect(() => {
if (!checkout) {
refreshCheckout();
@@ -189,25 +256,56 @@ export default function CheckoutPage() {
return;
}
// Validate all required fields
if (!shippingAddress.email || !shippingAddress.email.includes("@")) {
setError(t("errorEmailRequired"));
return;
}
if (!shippingAddress.firstName || !shippingAddress.lastName || !shippingAddress.streetAddress1 || !shippingAddress.city || !shippingAddress.postalCode || !shippingAddress.phone) {
if (!shippingAddress.phone || shippingAddress.phone.length < 8) {
setError(t("errorPhoneRequired"));
return;
}
if (!shippingAddress.firstName || !shippingAddress.lastName || !shippingAddress.streetAddress1 || !shippingAddress.city || !shippingAddress.postalCode) {
setError(t("errorFieldsRequired"));
return;
}
if (!selectedShippingMethod) {
setError(t("errorSelectShipping"));
return;
}
setIsLoading(true);
setError(null);
try {
// If we're showing shipping methods and one is selected, complete the order
if (showShippingMethods && selectedShippingMethod) {
console.log("Phase 2: Completing order with shipping method...");
console.log("Completing order...");
console.log("Step 1: Updating billing address...");
console.log("Step 1: Updating email...");
const emailResult = await saleorClient.mutate<EmailUpdateResponse>({
mutation: CHECKOUT_EMAIL_UPDATE,
variables: {
checkoutId: checkout.id,
email: shippingAddress.email,
},
});
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
const errorMessage = emailResult.data.checkoutEmailUpdate.errors[0].message;
if (errorMessage.includes("Couldn't resolve to a node")) {
console.error("Checkout not found, clearing cart...");
localStorage.removeItem('cart');
localStorage.removeItem('checkoutId');
window.location.href = `/${locale}/products`;
return;
}
throw new Error(`Email update failed: ${errorMessage}`);
}
console.log("Step 1: Email updated successfully");
console.log("Step 2: Updating billing address...");
const billingResult = await saleorClient.mutate<BillingAddressUpdateResponse>({
mutation: CHECKOUT_BILLING_ADDRESS_UPDATE,
variables: {
@@ -228,9 +326,9 @@ export default function CheckoutPage() {
if (billingResult.data?.checkoutBillingAddressUpdate?.errors && billingResult.data.checkoutBillingAddressUpdate.errors.length > 0) {
throw new Error(`Billing address update failed: ${billingResult.data.checkoutBillingAddressUpdate.errors[0].message}`);
}
console.log("Step 1: Billing address updated successfully");
console.log("Step 2: Billing address updated successfully");
console.log("Step 2: Setting shipping method...");
console.log("Step 3: Setting shipping method...");
const shippingMethodResult = await saleorClient.mutate<ShippingMethodUpdateResponse>({
mutation: CHECKOUT_SHIPPING_METHOD_UPDATE,
variables: {
@@ -242,9 +340,9 @@ export default function CheckoutPage() {
if (shippingMethodResult.data?.checkoutShippingMethodUpdate?.errors && shippingMethodResult.data.checkoutShippingMethodUpdate.errors.length > 0) {
throw new Error(`Shipping method update failed: ${shippingMethodResult.data.checkoutShippingMethodUpdate.errors[0].message}`);
}
console.log("Step 2: Shipping method set successfully");
console.log("Step 3: Shipping method set successfully");
console.log("Step 3: Saving phone number...");
console.log("Step 4: Saving metadata...");
const metadataResult = await saleorClient.mutate<MetadataUpdateResponse>({
mutation: CHECKOUT_METADATA_UPDATE,
variables: {
@@ -252,6 +350,8 @@ export default function CheckoutPage() {
metadata: [
{ key: "phone", value: shippingAddress.phone },
{ key: "shippingPhone", value: shippingAddress.phone },
{ key: "userLanguage", value: locale },
{ key: "userLocale", value: locale },
],
},
});
@@ -259,10 +359,10 @@ export default function CheckoutPage() {
if (metadataResult.data?.updateMetadata?.errors && metadataResult.data.updateMetadata.errors.length > 0) {
console.warn("Failed to save phone metadata:", metadataResult.data.updateMetadata.errors);
} else {
console.log("Step 3: Phone number saved successfully");
console.log("Step 4: Phone number saved successfully");
}
console.log("Step 4: Completing checkout...");
console.log("Step 5: Completing checkout...");
const completeResult = await saleorClient.mutate<CheckoutCompleteResponse>({
mutation: CHECKOUT_COMPLETE,
variables: {
@@ -302,84 +402,6 @@ export default function CheckoutPage() {
} else {
throw new Error(t("errorCreatingOrder"));
}
} else {
// Phase 1: Update email and address, then fetch shipping methods
console.log("Phase 1: Updating email and address...");
console.log("Step 1: Updating email...");
const emailResult = await saleorClient.mutate<EmailUpdateResponse>({
mutation: CHECKOUT_EMAIL_UPDATE,
variables: {
checkoutId: checkout.id,
email: shippingAddress.email,
},
});
if (emailResult.data?.checkoutEmailUpdate?.errors && emailResult.data.checkoutEmailUpdate.errors.length > 0) {
throw new Error(`Email update failed: ${emailResult.data.checkoutEmailUpdate.errors[0].message}`);
}
console.log("Step 1: Email updated successfully");
console.log("Step 2: Updating shipping address...");
console.log("Shipping address data:", {
firstName: shippingAddress.firstName,
lastName: shippingAddress.lastName,
streetAddress1: shippingAddress.streetAddress1,
city: shippingAddress.city,
postalCode: shippingAddress.postalCode,
country: shippingAddress.country,
phone: shippingAddress.phone,
});
const shippingResult = await saleorClient.mutate<ShippingAddressUpdateResponse>({
mutation: CHECKOUT_SHIPPING_ADDRESS_UPDATE,
variables: {
checkoutId: checkout.id,
shippingAddress: {
firstName: shippingAddress.firstName,
lastName: shippingAddress.lastName,
streetAddress1: shippingAddress.streetAddress1,
streetAddress2: shippingAddress.streetAddress2,
city: shippingAddress.city,
postalCode: shippingAddress.postalCode,
country: shippingAddress.country,
phone: shippingAddress.phone,
},
},
});
if (shippingResult.data?.checkoutShippingAddressUpdate?.errors && shippingResult.data.checkoutShippingAddressUpdate.errors.length > 0) {
throw new Error(`Shipping address update failed: ${shippingResult.data.checkoutShippingAddressUpdate.errors[0].message}`);
}
console.log("Step 2: Shipping address updated successfully");
// Query for checkout to get available shipping methods
console.log("Step 3: Fetching shipping methods...");
const checkoutQueryResult = await saleorClient.query<CheckoutQueryResponse>({
query: GET_CHECKOUT_BY_ID,
variables: {
id: checkout.id,
},
fetchPolicy: "network-only",
});
const availableMethods = checkoutQueryResult.data?.checkout?.shippingMethods || [];
console.log("Available shipping methods:", availableMethods);
if (availableMethods.length === 0) {
throw new Error(t("errorNoShippingMethods"));
}
setShippingMethods(availableMethods);
setShowShippingMethods(true);
// Track shipping step
trackCheckoutStep("shipping_method_selection", {
available_methods_count: availableMethods.length,
});
// Don't complete yet - show shipping method selection
console.log("Phase 1 complete. Waiting for shipping method selection...");
}
} catch (err: unknown) {
console.error("Checkout error:", err);
@@ -595,9 +617,17 @@ export default function CheckoutPage() {
</div>
{/* Shipping Method Selection */}
{showShippingMethods && shippingMethods.length > 0 && (
<div className="border-b border-border pb-6">
<h2 className="text-xl font-serif mb-4">{t("shippingMethod")}</h2>
<div className="border-b border-border pb-6">
<h2 className="text-xl font-serif mb-4">{t("shippingMethod")}</h2>
{isLoadingShipping ? (
<div className="flex items-center gap-2 text-foreground-muted">
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{t("loadingShippingMethods")}</span>
</div>
) : shippingMethods.length > 0 ? (
<div className="space-y-3">
{shippingMethods.map((method) => (
<label
@@ -625,18 +655,17 @@ export default function CheckoutPage() {
</label>
))}
</div>
{!selectedShippingMethod && (
<p className="text-red-500 text-sm mt-2">{t("errorSelectShipping")}</p>
)}
</div>
)}
) : (
<p className="text-foreground-muted">{t("enterAddressForShipping")}</p>
)}
</div>
<button
type="submit"
disabled={isLoading || lines.length === 0 || (showShippingMethods && !selectedShippingMethod)}
disabled={isLoading || lines.length === 0 || !selectedShippingMethod}
className="w-full py-4 bg-foreground text-white font-medium hover:bg-accent-dark transition-colors disabled:opacity-50"
>
{isLoading ? t("processing") : showShippingMethods ? t("completeOrder", { total: formatPrice(total) }) : t("continueToShipping")}
{isLoading ? t("processing") : t("completeOrder", { total: formatPrice(total) })}
</button>
</form>
</div>
@@ -680,6 +709,12 @@ export default function CheckoutPage() {
<span className="text-foreground-muted">{t("subtotal")}</span>
<span>{formatPrice(checkout?.subtotalPrice?.gross?.amount || 0)}</span>
</div>
{selectedShippingMethod && (
<div className="flex justify-between">
<span className="text-foreground-muted">{t("shipping")}</span>
<span>{formatPrice(shippingMethods.find(m => m.id === selectedShippingMethod)?.price.amount || 0)}</span>
</div>
)}
<div className="flex justify-between font-medium text-lg pt-2 border-t border-border">
<span>{t("total")}</span>
<span>{formatPrice(total)}</span>
-295
View File
@@ -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,
});
}
-6
View File
@@ -1,6 +0,0 @@
export function formatPrice(amount: number, currency: string): string {
return new Intl.NumberFormat("sr-RS", {
style: "currency",
currency: currency,
}).format(amount);
}
+7 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
@@ -30,14 +30,16 @@ export default function CartDrawer() {
const lines = getLines();
const total = getTotal();
const lineCount = getLineCount();
const [initialized, setInitialized] = useState(false);
const initializedRef = useRef(false);
useEffect(() => {
if (!initialized) {
if (!initializedRef.current && locale) {
// Set language code before initializing checkout
useSaleorCheckoutStore.getState().setLanguageCode(locale);
initCheckout();
setInitialized(true);
initializedRef.current = true;
}
}, [initialized]);
}, [locale]);
useEffect(() => {
if (isOpen) {
+5 -1
View File
@@ -15,9 +15,13 @@ interface NewHeroProps {
export default function NewHero({ featuredProduct }: NewHeroProps) {
const locale = useLocale();
const { addLine, openCart } = useSaleorCheckoutStore();
const { addLine, openCart, setLanguageCode } = useSaleorCheckoutStore();
const handleAddToCart = async () => {
// Set language code before adding to cart
if (locale) {
setLanguageCode(locale);
}
const variant = featuredProduct?.variants?.[0];
if (variant?.id) {
await addLine(variant.id, 1);
+11 -5
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { AnimatePresence, motion } from "framer-motion";
import { useTranslations } from "next-intl";
import { useTranslations, useLocale } from "next-intl";
import { useSaleorCheckoutStore } from "@/stores/saleorCheckoutStore";
import { User, ShoppingBag, Menu, X, Globe } from "lucide-react";
import CartDrawer from "@/components/cart/CartDrawer";
@@ -16,14 +16,15 @@ interface HeaderProps {
locale?: string;
}
export default function Header({ locale = "sr" }: HeaderProps) {
export default function Header({ locale: propLocale = "sr" }: HeaderProps) {
const t = useTranslations("Header");
const pathname = usePathname();
const dropdownRef = useRef<HTMLDivElement>(null);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const [langDropdownOpen, setLangDropdownOpen] = useState(false);
const { getLineCount, toggleCart, initCheckout } = useSaleorCheckoutStore();
const { getLineCount, toggleCart, initCheckout, setLanguageCode } = useSaleorCheckoutStore();
const locale = useLocale();
const itemCount = getLineCount();
const currentLocale = isValidLocale(locale) ? LOCALE_CONFIG[locale] : LOCALE_CONFIG.sr;
@@ -54,9 +55,14 @@ export default function Header({ locale = "sr" }: HeaderProps) {
setLangDropdownOpen(false);
};
// Set language code first, then initialize checkout
useEffect(() => {
initCheckout();
}, [initCheckout]);
if (locale) {
setLanguageCode(locale);
// Initialize checkout after language code is set
initCheckout();
}
}, [locale, setLanguageCode, initCheckout]);
useEffect(() => {
const handleScroll = () => {
+6 -1
View File
@@ -99,7 +99,7 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
const [isAdding, setIsAdding] = useState(false);
const [urgencyIndex, setUrgencyIndex] = useState(0);
const [selectedBundleVariantId, setSelectedBundleVariantId] = useState<string | null>(null);
const { addLine, openCart } = useSaleorCheckoutStore();
const { addLine, openCart, setLanguageCode } = useSaleorCheckoutStore();
const { trackProductView, trackAddToCart } = useAnalytics();
const validLocale = isValidLocale(locale) ? locale : "sr";
@@ -147,6 +147,11 @@ export default function ProductDetail({ product, relatedProducts, bundleProducts
const handleAddToCart = async () => {
if (!selectedVariantId) return;
// Set language code before adding to cart
if (validLocale) {
setLanguageCode(validLocale);
}
setIsAdding(true);
try {
await addLine(selectedVariantId, 1);
-98
View File
@@ -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,
},
};
-237
View File
@@ -1,237 +0,0 @@
import { Button, Hr, Section, Text } from "@react-email/components";
import { BaseLayout } from "./BaseLayout";
interface OrderItem {
id: string;
name: string;
quantity: number;
price: string;
}
interface OrderCancelledProps {
language: string;
orderId: string;
orderNumber: string;
customerName: string;
items: OrderItem[];
total: string;
reason?: string;
siteUrl: string;
}
const translations: Record<
string,
{
title: string;
preview: string;
greeting: string;
orderCancelled: string;
items: string;
total: string;
reason: string;
questions: string;
}
> = {
sr: {
title: "Vaša narudžbina je otkazana",
preview: "Vaša narudžbina je otkazana",
greeting: "Poštovani {name},",
orderCancelled:
"Vaša narudžbina je otkazana. Ako niste zatražili otkazivanje, molimo kontaktirajte nas što pre.",
items: "Artikli",
total: "Ukupno",
reason: "Razlog",
questions: "Imate pitanja? Pišite nam na [email protected]",
},
en: {
title: "Your Order Has Been Cancelled",
preview: "Your order has been cancelled",
greeting: "Dear {name},",
orderCancelled:
"Your order has been cancelled. If you did not request this cancellation, please contact us as soon as possible.",
items: "Items",
total: "Total",
reason: "Reason",
questions: "Questions? Email us at [email protected]",
},
de: {
title: "Ihre Bestellung wurde storniert",
preview: "Ihre Bestellung wurde storniert",
greeting: "Sehr geehrte/r {name},",
orderCancelled:
"Ihre Bestellung wurde storniert. Wenn Sie diese Stornierung nicht angefordert haben, kontaktieren Sie uns bitte so schnell wie möglich.",
items: "Artikel",
total: "Gesamt",
reason: "Grund",
questions: "Fragen? Schreiben Sie uns an [email protected]",
},
fr: {
title: "Votre commande a été annulée",
preview: "Votre commande a été annulée",
greeting: "Cher(e) {name},",
orderCancelled:
"Votre commande a été annulée. Si vous n'avez pas demandé cette annulation, veuillez nous contacter dès que possible.",
items: "Articles",
total: "Total",
reason: "Raison",
questions: "Questions? Écrivez-nous à [email protected]",
},
};
export function OrderCancelled({
language = "en",
orderId,
orderNumber,
customerName,
items,
total,
reason,
siteUrl,
}: OrderCancelledProps) {
const t = translations[language] || translations.en;
return (
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
<Text style={styles.title}>{t.title}</Text>
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
<Text style={styles.text}>{t.orderCancelled}</Text>
<Section style={styles.orderInfo}>
<Text style={styles.orderNumber}>
<strong>Order Number:</strong> {orderNumber}
</Text>
{reason && (
<Text style={styles.reason}>
<strong>{t.reason}:</strong> {reason}
</Text>
)}
</Section>
<Section style={styles.itemsSection}>
<Text style={styles.sectionTitle}>{t.items}</Text>
<Hr style={styles.hr} />
{items.map((item) => (
<Section key={item.id} style={styles.itemRow}>
<Text style={styles.itemName}>
{item.quantity}x {item.name}
</Text>
<Text style={styles.itemPrice}>{item.price}</Text>
</Section>
))}
<Hr style={styles.hr} />
<Section style={styles.totalRow}>
<Text style={styles.totalLabel}>{t.total}:</Text>
<Text style={styles.totalValue}>{total}</Text>
</Section>
</Section>
<Section style={styles.buttonSection}>
<Button href={siteUrl} style={styles.button}>
{language === "sr" ? "Pogledajte proizvode" : "Browse Products"}
</Button>
</Section>
<Text style={styles.questions}>{t.questions}</Text>
</BaseLayout>
);
}
const styles = {
title: {
fontSize: "24px",
fontWeight: "bold" as const,
color: "#dc2626",
marginBottom: "20px",
},
greeting: {
fontSize: "16px",
color: "#333333",
marginBottom: "10px",
},
text: {
fontSize: "14px",
color: "#666666",
marginBottom: "20px",
},
orderInfo: {
backgroundColor: "#fef2f2",
padding: "15px",
borderRadius: "8px",
marginBottom: "20px",
},
orderNumber: {
fontSize: "14px",
color: "#333333",
margin: "0 0 5px 0",
},
reason: {
fontSize: "14px",
color: "#991b1b",
margin: "0",
},
itemsSection: {
marginBottom: "20px",
},
sectionTitle: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#1a1a1a",
marginBottom: "10px",
},
hr: {
borderColor: "#e0e0e0",
margin: "10px 0",
},
itemRow: {
display: "flex" as const,
justifyContent: "space-between" as const,
padding: "8px 0",
},
itemName: {
fontSize: "14px",
color: "#666666",
margin: "0",
textDecoration: "line-through",
},
itemPrice: {
fontSize: "14px",
color: "#666666",
margin: "0",
textDecoration: "line-through",
},
totalRow: {
display: "flex" as const,
justifyContent: "space-between" as const,
padding: "8px 0",
},
totalLabel: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#666666",
margin: "0",
},
totalValue: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#666666",
margin: "0",
textDecoration: "line-through",
},
buttonSection: {
textAlign: "center" as const,
marginBottom: "20px",
},
button: {
backgroundColor: "#000000",
color: "#ffffff",
padding: "12px 30px",
borderRadius: "4px",
fontSize: "14px",
fontWeight: "bold" as const,
textDecoration: "none",
},
questions: {
fontSize: "14px",
color: "#666666",
},
};
-394
View File
@@ -1,394 +0,0 @@
import { Button, Hr, Section, Text } from "@react-email/components";
import { BaseLayout } from "./BaseLayout";
interface OrderItem {
id: string;
name: string;
quantity: number;
price: string;
}
interface OrderConfirmationProps {
language: string;
orderId: string;
orderNumber: string;
customerEmail: string;
customerName: string;
items: OrderItem[];
total: string;
shippingAddress?: string;
billingAddress?: string;
phone?: string;
siteUrl: string;
dashboardUrl?: string;
isAdmin?: boolean;
}
const translations: Record<
string,
{
title: string;
preview: string;
greeting: string;
orderReceived: string;
orderNumber: string;
items: string;
quantity: string;
total: string;
shippingTo: string;
questions: string;
thankYou: string;
adminTitle: string;
adminPreview: string;
adminGreeting: string;
adminMessage: string;
customerLabel: string;
customerEmailLabel: string;
billingAddressLabel: string;
phoneLabel: string;
viewDashboard: string;
}
> = {
sr: {
title: "Potvrda narudžbine",
preview: "Vaša narudžbina je potvrđena",
greeting: "Poštovani {name},",
orderReceived: "Zahvaljujemo se na Vašoj narudžbini! Primili smo je i sada je u pripremi.",
orderNumber: "Broj narudžbine",
items: "Artikli",
quantity: "Količina",
total: "Ukupno",
shippingTo: "Adresa za dostavu",
questions: "Imate pitanja? Pišite nam na [email protected]",
thankYou: "Hvala Vam što kupujete kod nas!",
adminTitle: "Nova narudžbina!",
adminPreview: "Nova narudžbina je primljena",
adminGreeting: "Čestitamo na prodaji!",
adminMessage: "Nova narudžbina je upravo primljena. Detalji su ispod:",
customerLabel: "Kupac",
customerEmailLabel: "Email kupca",
billingAddressLabel: "Adresa za naplatu",
phoneLabel: "Telefon",
viewDashboard: "Pogledaj u Dashboardu",
},
en: {
title: "Order Confirmation",
preview: "Your order has been confirmed",
greeting: "Dear {name},",
orderReceived:
"Thank you for your order! We have received it and it is now being processed.",
orderNumber: "Order number",
items: "Items",
quantity: "Quantity",
total: "Total",
shippingTo: "Shipping address",
questions: "Questions? Email us at [email protected]",
thankYou: "Thank you for shopping with us!",
adminTitle: "New Order! 🎉",
adminPreview: "A new order has been received",
adminGreeting: "Congratulations on the sale!",
adminMessage: "A new order has just been placed. Details below:",
customerLabel: "Customer",
customerEmailLabel: "Customer Email",
billingAddressLabel: "Billing Address",
phoneLabel: "Phone",
viewDashboard: "View in Dashboard",
},
de: {
title: "Bestellungsbestätigung",
preview: "Ihre Bestellung wurde bestätigt",
greeting: "Sehr geehrte/r {name},",
orderReceived:
"Vielen Dank für Ihre Bestellung! Wir haben sie erhalten und sie wird nun bearbeitet.",
orderNumber: "Bestellnummer",
items: "Artikel",
quantity: "Menge",
total: "Gesamt",
shippingTo: "Lieferadresse",
questions: "Fragen? Schreiben Sie uns an [email protected]",
thankYou: "Vielen Dank für Ihren Einkauf!",
adminTitle: "Neue Bestellung! 🎉",
adminPreview: "Eine neue Bestellung wurde erhalten",
adminGreeting: "Glückwunsch zum Verkauf!",
adminMessage: "Eine neue Bestellung wurde soeben aufgegeben. Details unten:",
customerLabel: "Kunde",
customerEmailLabel: "Kunden-E-Mail",
billingAddressLabel: "Rechnungsadresse",
phoneLabel: "Telefon",
viewDashboard: "Im Dashboard anzeigen",
},
fr: {
title: "Confirmation de commande",
preview: "Votre commande a été confirmée",
greeting: "Cher(e) {name},",
orderReceived:
"Merci pour votre commande! Nous l'avons reçue et elle est en cours de traitement.",
orderNumber: "Numéro de commande",
items: "Articles",
quantity: "Quantité",
total: "Total",
shippingTo: "Adresse de livraison",
questions: "Questions? Écrivez-nous à [email protected]",
thankYou: "Merci d'avoir Magasiné avec nous!",
adminTitle: "Nouvelle commande! 🎉",
adminPreview: "Une nouvelle commande a été reçue",
adminGreeting: "Félicitations pour la vente!",
adminMessage: "Une nouvelle commande vient d'être passée. Détails ci-dessous:",
customerLabel: "Client",
customerEmailLabel: "Email du client",
billingAddressLabel: "Adresse de facturation",
phoneLabel: "Téléphone",
viewDashboard: "Voir dans le Dashboard",
},
};
export function OrderConfirmation({
language = "en",
orderId,
orderNumber,
customerEmail,
customerName,
items,
total,
shippingAddress,
billingAddress,
phone,
siteUrl,
dashboardUrl,
isAdmin = false,
}: OrderConfirmationProps) {
const t = translations[language] || translations.en;
// For admin emails, always use English
const adminT = translations["en"];
if (isAdmin) {
return (
<BaseLayout previewText={adminT.adminPreview} language="en" siteUrl={siteUrl}>
<Text style={styles.title}>{adminT.adminTitle}</Text>
<Text style={styles.greeting}>{adminT.adminGreeting}</Text>
<Text style={styles.text}>{adminT.adminMessage}</Text>
<Section style={styles.orderInfo}>
<Text style={styles.orderNumber}>
<strong>{adminT.orderNumber}:</strong> {orderNumber}
</Text>
<Text style={styles.customerInfo}>
<strong>{adminT.customerLabel}:</strong> {customerName}
</Text>
<Text style={styles.customerInfo}>
<strong>{adminT.customerEmailLabel}:</strong> {customerEmail}
</Text>
{phone && (
<Text style={styles.customerInfo}>
<strong>{adminT.phoneLabel}:</strong> {phone}
</Text>
)}
</Section>
<Section style={styles.itemsSection}>
<Text style={styles.sectionTitle}>{adminT.items}</Text>
<Hr style={styles.hr} />
{items.map((item) => (
<Section key={item.id} style={styles.itemRow}>
<Text style={styles.itemName}>
{item.quantity}x {item.name}
</Text>
<Text style={styles.itemPrice}>{item.price}</Text>
</Section>
))}
<Hr style={styles.hr} />
<Section style={styles.totalRow}>
<Text style={styles.totalLabel}>{adminT.total}:</Text>
<Text style={styles.totalValue}>{total}</Text>
</Section>
</Section>
{shippingAddress && (
<Section style={styles.shippingSection}>
<Text style={styles.sectionTitle}>{adminT.shippingTo}</Text>
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
</Section>
)}
{billingAddress && (
<Section style={styles.shippingSection}>
<Text style={styles.sectionTitle}>{adminT.billingAddressLabel}</Text>
<Text style={styles.shippingAddress}>{billingAddress}</Text>
</Section>
)}
<Section style={styles.buttonSection}>
<Button href={`${dashboardUrl}/orders/${orderId}`} style={styles.button}>
{adminT.viewDashboard}
</Button>
</Section>
</BaseLayout>
);
}
return (
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
<Text style={styles.title}>{t.title}</Text>
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
<Text style={styles.text}>{t.orderReceived}</Text>
<Section style={styles.orderInfo}>
<Text style={styles.orderNumber}>
<strong>{t.orderNumber}:</strong> {orderNumber}
</Text>
</Section>
<Section style={styles.itemsSection}>
<Text style={styles.sectionTitle}>{t.items}</Text>
<Hr style={styles.hr} />
{items.map((item) => (
<Section key={item.id} style={styles.itemRow}>
<Text style={styles.itemName}>
{item.quantity}x {item.name}
</Text>
<Text style={styles.itemPrice}>{item.price}</Text>
</Section>
))}
<Hr style={styles.hr} />
<Section style={styles.totalRow}>
<Text style={styles.totalLabel}>{t.total}:</Text>
<Text style={styles.totalValue}>{total}</Text>
</Section>
</Section>
{shippingAddress && (
<Section style={styles.shippingSection}>
<Text style={styles.sectionTitle}>{t.shippingTo}</Text>
<Text style={styles.shippingAddress}>{shippingAddress}</Text>
</Section>
)}
<Section style={styles.buttonSection}>
<Button href={siteUrl} style={styles.button}>
{language === "sr"
? "Pogledajte narudžbinu"
: language === "de"
? "Bestellung ansehen"
: language === "fr"
? "Voir la commande"
: "View Order"}
</Button>
</Section>
<Text style={styles.questions}>{t.questions}</Text>
<Text style={styles.thankYou}>{t.thankYou}</Text>
</BaseLayout>
);
}
const styles = {
title: {
fontSize: "24px",
fontWeight: "bold" as const,
color: "#1a1a1a",
marginBottom: "20px",
},
greeting: {
fontSize: "16px",
color: "#333333",
marginBottom: "10px",
},
text: {
fontSize: "14px",
color: "#666666",
marginBottom: "20px",
},
orderInfo: {
backgroundColor: "#f9f9f9",
padding: "15px",
borderRadius: "8px",
marginBottom: "20px",
},
orderNumber: {
fontSize: "14px",
color: "#333333",
margin: "0 0 8px 0",
},
customerInfo: {
fontSize: "14px",
color: "#333333",
margin: "0 0 4px 0",
},
itemsSection: {
marginBottom: "20px",
},
sectionTitle: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#1a1a1a",
marginBottom: "10px",
},
hr: {
borderColor: "#e0e0e0",
margin: "10px 0",
},
itemRow: {
display: "flex" as const,
justifyContent: "space-between" as const,
padding: "8px 0",
},
itemName: {
fontSize: "14px",
color: "#333333",
margin: "0",
},
itemPrice: {
fontSize: "14px",
color: "#333333",
margin: "0",
},
totalRow: {
display: "flex" as const,
justifyContent: "space-between" as const,
padding: "8px 0",
},
totalLabel: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#1a1a1a",
margin: "0",
},
totalValue: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#1a1a1a",
margin: "0",
},
shippingSection: {
marginBottom: "20px",
},
shippingAddress: {
fontSize: "14px",
color: "#666666",
margin: "0",
},
buttonSection: {
textAlign: "center" as const,
marginBottom: "20px",
},
button: {
backgroundColor: "#000000",
color: "#ffffff",
padding: "12px 30px",
borderRadius: "4px",
fontSize: "14px",
fontWeight: "bold" as const,
textDecoration: "none",
},
questions: {
fontSize: "14px",
color: "#666666",
marginBottom: "10px",
},
thankYou: {
fontSize: "14px",
fontWeight: "bold" as const,
color: "#1a1a1a",
},
};
-253
View File
@@ -1,253 +0,0 @@
import { Button, Hr, Section, Text } from "@react-email/components";
import { BaseLayout } from "./BaseLayout";
interface OrderItem {
id: string;
name: string;
quantity: number;
price: string;
}
interface OrderPaidProps {
language: string;
orderId: string;
orderNumber: string;
customerName: string;
items: OrderItem[];
total: string;
siteUrl: string;
}
const translations: Record<
string,
{
title: string;
preview: string;
greeting: string;
orderPaid: string;
items: string;
total: string;
nextSteps: string;
nextStepsText: string;
questions: string;
}
> = {
sr: {
title: "Plaćanje je primljeno!",
preview: "Vaša uplata je zabeležena",
greeting: "Poštovani {name},",
orderPaid:
"Plaćanje za vašu narudžbinu je primljeno. Hvala vam! Narudžbina će uskoro biti spremna za slanje.",
items: "Artikli",
total: "Ukupno",
nextSteps: "Šta dalje?",
nextStepsText:
"Primićete još jedan email kada vaša narudžbina bude poslata. Možete očekivati dostavu u roku od 3-5 radnih dana.",
questions: "Imate pitanja? Pišite nam na [email protected]",
},
en: {
title: "Payment Received!",
preview: "Your payment has been recorded",
greeting: "Dear {name},",
orderPaid:
"Payment for your order has been received. Thank you! Your order will be prepared for shipping soon.",
items: "Items",
total: "Total",
nextSteps: "What's next?",
nextStepsText:
"You will receive another email when your order ships. You can expect delivery within 3-5 business days.",
questions: "Questions? Email us at [email protected]",
},
de: {
title: "Zahlung erhalten!",
preview: "Ihre Zahlung wurde verbucht",
greeting: "Sehr geehrte/r {name},",
orderPaid:
"Zahlung für Ihre Bestellung ist eingegangen. Vielen Dank! Ihre Bestellung wird bald für den Versand vorbereitet.",
items: "Artikel",
total: "Gesamt",
nextSteps: "Was kommt als nächstes?",
nextStepsText:
"Sie erhalten eine weitere E-Mail, wenn Ihre Bestellung versandt wird. Die Lieferung erfolgt innerhalb von 3-5 Werktagen.",
questions: "Fragen? Schreiben Sie uns an [email protected]",
},
fr: {
title: "Paiement reçu!",
preview: "Votre paiement a été enregistré",
greeting: "Cher(e) {name},",
orderPaid:
"Le paiement de votre commande a été reçu. Merci! Votre commande sera bientôt prête à être expédiée.",
items: "Articles",
total: "Total",
nextSteps: "Et ensuite?",
nextStepsText:
"Vous recevrez un autre email lorsque votre commande sera expédiée. Vous pouvez vous attendre à une livraison dans 3-5 jours ouvrables.",
questions: "Questions? Écrivez-nous à [email protected]",
},
};
export function OrderPaid({
language = "en",
orderId,
orderNumber,
customerName,
items,
total,
siteUrl,
}: OrderPaidProps) {
const t = translations[language] || translations.en;
return (
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
<Text style={styles.title}>{t.title}</Text>
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
<Text style={styles.text}>{t.orderPaid}</Text>
<Section style={styles.orderInfo}>
<Text style={styles.orderNumber}>
<strong>Order Number:</strong> {orderNumber}
</Text>
</Section>
<Section style={styles.itemsSection}>
<Text style={styles.sectionTitle}>{t.items}</Text>
<Hr style={styles.hr} />
{items.map((item) => (
<Section key={item.id} style={styles.itemRow}>
<Text style={styles.itemName}>
{item.quantity}x {item.name}
</Text>
<Text style={styles.itemPrice}>{item.price}</Text>
</Section>
))}
<Hr style={styles.hr} />
<Section style={styles.totalRow}>
<Text style={styles.totalLabel}>{t.total}:</Text>
<Text style={styles.totalValue}>{total}</Text>
</Section>
</Section>
<Section style={styles.nextSteps}>
<Text style={styles.nextStepsTitle}>{t.nextSteps}</Text>
<Text style={styles.nextStepsText}>{t.nextStepsText}</Text>
</Section>
<Section style={styles.buttonSection}>
<Button href={siteUrl} style={styles.button}>
{language === "sr" ? "Nastavite kupovinu" : "Continue Shopping"}
</Button>
</Section>
<Text style={styles.questions}>{t.questions}</Text>
</BaseLayout>
);
}
const styles = {
title: {
fontSize: "24px",
fontWeight: "bold" as const,
color: "#16a34a",
marginBottom: "20px",
},
greeting: {
fontSize: "16px",
color: "#333333",
marginBottom: "10px",
},
text: {
fontSize: "14px",
color: "#666666",
marginBottom: "20px",
},
orderInfo: {
backgroundColor: "#f0fdf4",
padding: "15px",
borderRadius: "8px",
marginBottom: "20px",
},
orderNumber: {
fontSize: "14px",
color: "#333333",
margin: "0",
},
itemsSection: {
marginBottom: "20px",
},
sectionTitle: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#1a1a1a",
marginBottom: "10px",
},
hr: {
borderColor: "#e0e0e0",
margin: "10px 0",
},
itemRow: {
display: "flex" as const,
justifyContent: "space-between" as const,
padding: "8px 0",
},
itemName: {
fontSize: "14px",
color: "#333333",
margin: "0",
},
itemPrice: {
fontSize: "14px",
color: "#333333",
margin: "0",
},
totalRow: {
display: "flex" as const,
justifyContent: "space-between" as const,
padding: "8px 0",
},
totalLabel: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#1a1a1a",
margin: "0",
},
totalValue: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#1a1a1a",
margin: "0",
},
nextSteps: {
backgroundColor: "#f9f9f9",
padding: "15px",
borderRadius: "8px",
marginBottom: "20px",
},
nextStepsTitle: {
fontSize: "14px",
fontWeight: "bold" as const,
color: "#1a1a1a",
marginBottom: "5px",
},
nextStepsText: {
fontSize: "14px",
color: "#666666",
margin: "0",
},
buttonSection: {
textAlign: "center" as const,
marginBottom: "20px",
},
button: {
backgroundColor: "#000000",
color: "#ffffff",
padding: "12px 30px",
borderRadius: "4px",
fontSize: "14px",
fontWeight: "bold" as const,
textDecoration: "none",
},
questions: {
fontSize: "14px",
color: "#666666",
},
};
-193
View File
@@ -1,193 +0,0 @@
import { Button, Hr, Section, Text } from "@react-email/components";
import { BaseLayout } from "./BaseLayout";
interface OrderItem {
id: string;
name: string;
quantity: number;
price: string;
}
interface OrderShippedProps {
language: string;
orderId: string;
orderNumber: string;
customerName: string;
items: OrderItem[];
trackingNumber?: string;
trackingUrl?: string;
siteUrl: string;
}
const translations: Record<
string,
{
title: string;
preview: string;
greeting: string;
orderShipped: string;
tracking: string;
items: string;
questions: string;
}
> = {
sr: {
title: "Vaša narudžbina je poslata!",
preview: "Vaša narudžbina je na putu",
greeting: "Poštovani {name},",
orderShipped:
"Odlične vesti! Vaša narudžbina je poslata i uskoro će stići na vašu adresu.",
tracking: "Praćenje pošiljke",
items: "Artikli",
questions: "Imate pitanja? Pišite nam na [email protected]",
},
en: {
title: "Your Order Has Shipped!",
preview: "Your order is on its way",
greeting: "Dear {name},",
orderShipped:
"Great news! Your order has been shipped and will arrive at your address soon.",
tracking: "Track your shipment",
items: "Items",
questions: "Questions? Email us at [email protected]",
},
de: {
title: "Ihre Bestellung wurde versendet!",
preview: "Ihre Bestellung ist unterwegs",
greeting: "Sehr geehrte/r {name},",
orderShipped:
"Großartige Neuigkeiten! Ihre Bestellung wurde versandt und wird in Kürze bei Ihnen eintreffen.",
tracking: "Sendung verfolgen",
items: "Artikel",
questions: "Fragen? Schreiben Sie uns an [email protected]",
},
fr: {
title: "Votre commande a été expédiée!",
preview: "Votre commande est en route",
greeting: "Cher(e) {name},",
orderShipped:
"Bonne nouvelle! Votre commande a été expédiée et arrivera bientôt à votre adresse.",
tracking: "Suivre votre envoi",
items: "Articles",
questions: "Questions? Écrivez-nous à [email protected]",
},
};
export function OrderShipped({
language = "en",
orderId,
orderNumber,
customerName,
items,
trackingNumber,
trackingUrl,
siteUrl,
}: OrderShippedProps) {
const t = translations[language] || translations.en;
return (
<BaseLayout previewText={t.preview} language={language} siteUrl={siteUrl}>
<Text style={styles.title}>{t.title}</Text>
<Text style={styles.greeting}>{t.greeting.replace("{name}", customerName)}</Text>
<Text style={styles.text}>{t.orderShipped}</Text>
{trackingNumber && (
<Section style={styles.trackingSection}>
<Text style={styles.sectionTitle}>{t.tracking}</Text>
{trackingUrl ? (
<Button href={trackingUrl} style={styles.trackingButton}>
{trackingNumber}
</Button>
) : (
<Text style={styles.trackingNumber}>{trackingNumber}</Text>
)}
</Section>
)}
<Section style={styles.itemsSection}>
<Text style={styles.sectionTitle}>{t.items}</Text>
<Hr style={styles.hr} />
{items.map((item) => (
<Section key={item.id} style={styles.itemRow}>
<Text style={styles.itemName}>
{item.quantity}x {item.name}
</Text>
<Text style={styles.itemPrice}>{item.price}</Text>
</Section>
))}
</Section>
<Text style={styles.questions}>{t.questions}</Text>
</BaseLayout>
);
}
const styles = {
title: {
fontSize: "24px",
fontWeight: "bold" as const,
color: "#1a1a1a",
marginBottom: "20px",
},
greeting: {
fontSize: "16px",
color: "#333333",
marginBottom: "10px",
},
text: {
fontSize: "14px",
color: "#666666",
marginBottom: "20px",
},
trackingSection: {
backgroundColor: "#f9f9f9",
padding: "15px",
borderRadius: "8px",
marginBottom: "20px",
},
sectionTitle: {
fontSize: "16px",
fontWeight: "bold" as const,
color: "#1a1a1a",
marginBottom: "10px",
},
trackingNumber: {
fontSize: "14px",
color: "#333333",
margin: "0",
},
trackingButton: {
backgroundColor: "#000000",
color: "#ffffff",
padding: "10px 20px",
borderRadius: "4px",
fontSize: "14px",
textDecoration: "none",
},
itemsSection: {
marginBottom: "20px",
},
hr: {
borderColor: "#e0e0e0",
margin: "10px 0",
},
itemRow: {
display: "flex" as const,
justifyContent: "space-between" as const,
padding: "8px 0",
},
itemName: {
fontSize: "14px",
color: "#333333",
margin: "0",
},
itemPrice: {
fontSize: "14px",
color: "#333333",
margin: "0",
},
questions: {
fontSize: "14px",
color: "#666666",
},
};
-5
View File
@@ -1,5 +0,0 @@
export { BaseLayout } from "./BaseLayout";
export { OrderConfirmation } from "./OrderConfirmation";
export { OrderShipped } from "./OrderShipped";
export { OrderCancelled } from "./OrderCancelled";
export { OrderPaid } from "./OrderPaid";
+5
View File
@@ -372,6 +372,11 @@
"errorNoCheckout": "Keine aktive Kasse. Bitte versuchen Sie es erneut.",
"errorEmailRequired": "Bitte geben Sie eine gültige E-Mail-Adresse ein.",
"errorFieldsRequired": "Bitte füllen Sie alle erforderlichen Felder aus.",
"errorNoShippingMethods": "Keine Versandmethoden für diese Adresse verfügbar. Bitte überprüfen Sie Ihre Adresse oder kontaktieren Sie den Support.",
"errorSelectShipping": "Bitte wählen Sie eine Versandmethode.",
"errorPhoneRequired": "Bitte geben Sie eine gültige Telefonnummer ein.",
"loadingShippingMethods": "Versandoptionen werden geladen...",
"enterAddressForShipping": "Geben Sie Ihre Adresse ein, um Versandoptionen zu sehen.",
"errorOccurred": "Ein Fehler ist during des Checkouts aufgetreten.",
"errorCreatingOrder": "Bestellung konnte nicht erstellt werden.",
"orderConfirmed": "Bestellung bestätigt!",
+3
View File
@@ -420,6 +420,9 @@
"errorFieldsRequired": "Please fill in all required fields.",
"errorNoShippingMethods": "No shipping methods available for this address. Please check your address or contact support.",
"errorSelectShipping": "Please select a shipping method.",
"errorPhoneRequired": "Please enter a valid phone number.",
"loadingShippingMethods": "Loading shipping options...",
"enterAddressForShipping": "Enter your address to see shipping options.",
"errorOccurred": "An error occurred during checkout.",
"errorCreatingOrder": "Failed to create order.",
"continueToShipping": "Continue to Shipping",
+5
View File
@@ -372,6 +372,11 @@
"errorNoCheckout": "Pas de paiement actif. Veuillez réessayer.",
"errorEmailRequired": "Veuillez entrer une adresse e-mail valide.",
"errorFieldsRequired": "Veuillez remplir tous les champs obligatoires.",
"errorNoShippingMethods": "Aucune méthode de livraison disponible pour cette adresse. Veuillez vérifier votre adresse ou contacter le support.",
"errorSelectShipping": "Veuillez sélectionner une méthode de livraison.",
"errorPhoneRequired": "Veuillez entrer un numéro de téléphone valide.",
"loadingShippingMethods": "Chargement des options de livraison...",
"enterAddressForShipping": "Entrez votre adresse pour voir les options de livraison.",
"errorOccurred": "Une erreur s'est produite lors du paiement.",
"errorCreatingOrder": "Échec de la création de la commande.",
"orderConfirmed": "Commande Confirmée!",
+5
View File
@@ -418,6 +418,11 @@
"errorNoCheckout": "Nema aktivne korpe. Molimo pokušajte ponovo.",
"errorEmailRequired": "Molimo unesite validnu email adresu.",
"errorFieldsRequired": "Molimo popunite sva obavezna polja.",
"errorNoShippingMethods": "Nema dostupnih načina dostave za ovu adresu. Molimo proverite adresu ili kontaktirajte podršku.",
"errorSelectShipping": "Molimo izaberite način dostave.",
"errorPhoneRequired": "Molimo unesite validan broj telefona.",
"loadingShippingMethods": "Učitavanje opcija dostave...",
"enterAddressForShipping": "Unesite adresu da vidite opcije dostave.",
"errorOccurred": "Došlo je do greške prilikom kupovine.",
"errorCreatingOrder": "Neuspešno kreiranje narudžbine.",
"orderConfirmed": "Narudžbina potvrđena!",
@@ -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;
+13 -8
View File
@@ -58,22 +58,24 @@ interface GetCheckoutResponse {
interface SaleorCheckoutStore {
checkout: Checkout | null;
checkoutToken: string | null;
languageCode: string | null;
isOpen: boolean;
isLoading: boolean;
error: string | null;
// Actions
initCheckout: () => Promise<void>;
addLine: (variantId: string, quantity: number) => Promise<void>;
updateLine: (lineId: string, quantity: number) => Promise<void>;
removeLine: (lineId: string) => Promise<void>;
setEmail: (email: string) => Promise<void>;
setLanguageCode: (languageCode: string) => void;
refreshCheckout: () => Promise<void>;
toggleCart: () => void;
openCart: () => void;
closeCart: () => void;
clearError: () => void;
// Getters
getLineCount: () => number;
getTotal: () => number;
@@ -85,13 +87,14 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
(set, get) => ({
checkout: null,
checkoutToken: null,
languageCode: null,
isOpen: false,
isLoading: false,
error: null,
initCheckout: async () => {
const { checkoutToken } = get();
const { checkoutToken, languageCode } = get();
if (checkoutToken) {
// Try to fetch existing checkout
try {
@@ -99,7 +102,7 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
query: GET_CHECKOUT,
variables: { token: checkoutToken },
});
if (data?.checkout) {
set({ checkout: data.checkout });
return;
@@ -108,8 +111,8 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
// Checkout not found or expired, create new one
}
}
// Create new checkout
// Create new checkout with language code
try {
const { data } = await saleorClient.mutate<CheckoutCreateResponse>({
mutation: CHECKOUT_CREATE,
@@ -117,10 +120,11 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
input: {
channel: CHANNEL,
lines: [],
languageCode: languageCode ? languageCode.toUpperCase() : undefined,
},
},
});
if (data?.checkoutCreate?.checkout) {
set({
checkout: data.checkoutCreate.checkout,
@@ -294,6 +298,7 @@ export const useSaleorCheckoutStore = create<SaleorCheckoutStore>()(
openCart: () => set({ isOpen: true }),
closeCart: () => set({ isOpen: false }),
clearError: () => set({ error: null }),
setLanguageCode: (languageCode: string) => set({ languageCode }),
getLineCount: () => {
const { checkout } = get();