Merge branch 'feature/cash-on-delivery' into dev
This commit is contained in:
388
docs/ANALYTICS_GUIDE.md
Normal file
388
docs/ANALYTICS_GUIDE.md
Normal file
@@ -0,0 +1,388 @@
|
||||
# Comprehensive OpenPanel Analytics Guide
|
||||
|
||||
This guide documents all tracking events implemented in the ManoonOils storefront.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
|
||||
function MyComponent() {
|
||||
const { trackProductView, trackAddToCart, trackOrderCompleted } = useAnalytics();
|
||||
|
||||
// Use tracking functions...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## E-Commerce Events
|
||||
|
||||
### 1. Product Views
|
||||
|
||||
**trackProductView** - Track when user views a product
|
||||
```typescript
|
||||
trackProductView({
|
||||
id: "prod_123",
|
||||
name: "Manoon Anti-Age Serum",
|
||||
price: 2890,
|
||||
currency: "RSD",
|
||||
category: "Serums",
|
||||
sku: "MAN-001",
|
||||
in_stock: true,
|
||||
});
|
||||
```
|
||||
|
||||
**trackProductImageView** - Track product image gallery interactions
|
||||
```typescript
|
||||
trackProductImageView("prod_123", 2); // Viewed 3rd image
|
||||
```
|
||||
|
||||
**trackVariantSelect** - Track variant/option selection
|
||||
```typescript
|
||||
trackVariantSelect("prod_123", "50ml", 2890);
|
||||
```
|
||||
|
||||
### 2. Cart Events
|
||||
|
||||
**trackAddToCart** - Track adding items to cart
|
||||
```typescript
|
||||
trackAddToCart({
|
||||
id: "prod_123",
|
||||
name: "Manoon Anti-Age Serum",
|
||||
price: 2890,
|
||||
currency: "RSD",
|
||||
quantity: 2,
|
||||
variant: "50ml",
|
||||
sku: "MAN-001-50",
|
||||
});
|
||||
```
|
||||
|
||||
**trackRemoveFromCart** - Track removing items from cart
|
||||
```typescript
|
||||
trackRemoveFromCart({
|
||||
id: "prod_123",
|
||||
name: "Manoon Anti-Age Serum",
|
||||
price: 2890,
|
||||
quantity: 1,
|
||||
variant: "50ml",
|
||||
});
|
||||
```
|
||||
|
||||
**trackQuantityChange** - Track quantity adjustments
|
||||
```typescript
|
||||
trackQuantityChange(
|
||||
cartItem,
|
||||
1, // old quantity
|
||||
3 // new quantity
|
||||
);
|
||||
```
|
||||
|
||||
**trackCartOpen** - Track cart drawer/modal open
|
||||
```typescript
|
||||
trackCartOpen({
|
||||
total: 5780,
|
||||
currency: "RSD",
|
||||
item_count: 2,
|
||||
items: [/* cart items */],
|
||||
coupon_code: "SAVE10",
|
||||
});
|
||||
```
|
||||
|
||||
**trackCartAbandonment** - Track cart abandonment
|
||||
```typescript
|
||||
trackCartAbandonment(
|
||||
cartData,
|
||||
45000 // time spent in cart (ms)
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Checkout Events
|
||||
|
||||
**trackCheckoutStarted** - Track checkout initiation
|
||||
```typescript
|
||||
trackCheckoutStarted({
|
||||
total: 5780,
|
||||
currency: "RSD",
|
||||
item_count: 2,
|
||||
items: [/* cart items */],
|
||||
coupon_code: "SAVE10",
|
||||
});
|
||||
```
|
||||
|
||||
**trackCheckoutStep** - Track checkout step progression
|
||||
```typescript
|
||||
// Step progression
|
||||
trackCheckoutStep({
|
||||
step: "email",
|
||||
value: 5780,
|
||||
currency: "RSD",
|
||||
});
|
||||
|
||||
// With error
|
||||
trackCheckoutStep({
|
||||
step: "shipping",
|
||||
error: "Invalid postal code",
|
||||
});
|
||||
|
||||
// Final step
|
||||
trackCheckoutStep({
|
||||
step: "complete",
|
||||
payment_method: "cod",
|
||||
shipping_method: "Standard",
|
||||
});
|
||||
```
|
||||
|
||||
**trackPaymentMethodSelect** - Track payment method selection
|
||||
```typescript
|
||||
trackPaymentMethodSelect("cod", 5780);
|
||||
```
|
||||
|
||||
**trackShippingMethodSelect** - Track shipping method selection
|
||||
```typescript
|
||||
trackShippingMethodSelect("Standard", 480);
|
||||
```
|
||||
|
||||
### 4. Order Events
|
||||
|
||||
**trackOrderCompleted** - Track successful order with revenue
|
||||
```typescript
|
||||
trackOrderCompleted({
|
||||
order_id: "order_uuid",
|
||||
order_number: "1599",
|
||||
total: 6260,
|
||||
currency: "RSD",
|
||||
item_count: 2,
|
||||
shipping_cost: 480,
|
||||
customer_email: "customer@example.com",
|
||||
payment_method: "cod",
|
||||
coupon_code: "SAVE10",
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Engagement Events
|
||||
|
||||
### 1. Search
|
||||
|
||||
**trackSearch** - Track search queries
|
||||
```typescript
|
||||
trackSearch({
|
||||
query: "anti aging serum",
|
||||
results_count: 12,
|
||||
filters: { category: "serums", price_range: "2000-3000" },
|
||||
category: "serums",
|
||||
});
|
||||
```
|
||||
|
||||
### 2. General Engagement
|
||||
|
||||
**trackEngagement** - Track element interactions
|
||||
```typescript
|
||||
// Element click
|
||||
trackEngagement({
|
||||
element: "hero_cta",
|
||||
action: "click",
|
||||
value: "Shop Now",
|
||||
});
|
||||
|
||||
// Element hover
|
||||
trackEngagement({
|
||||
element: "product_card",
|
||||
action: "hover",
|
||||
value: "prod_123",
|
||||
});
|
||||
|
||||
// Element view (scroll into view)
|
||||
trackEngagement({
|
||||
element: "testimonials_section",
|
||||
action: "view",
|
||||
metadata: { section_position: "below_fold" },
|
||||
});
|
||||
```
|
||||
|
||||
### 3. CTA Tracking
|
||||
|
||||
**trackCTAClick** - Track call-to-action buttons
|
||||
```typescript
|
||||
trackCTAClick(
|
||||
"Shop Now", // CTA name
|
||||
"hero_section", // Location
|
||||
"/products" // Destination (optional)
|
||||
);
|
||||
```
|
||||
|
||||
### 4. External Links
|
||||
|
||||
**trackExternalLink** - Track outbound links
|
||||
```typescript
|
||||
trackExternalLink(
|
||||
"https://instagram.com/manoonoils",
|
||||
"Instagram",
|
||||
"footer"
|
||||
);
|
||||
```
|
||||
|
||||
### 5. Newsletter
|
||||
|
||||
**trackNewsletterSignup** - Track email subscriptions
|
||||
```typescript
|
||||
trackNewsletterSignup(
|
||||
"customer@example.com",
|
||||
"footer" // Location of signup form
|
||||
);
|
||||
```
|
||||
|
||||
### 6. Promo Codes
|
||||
|
||||
**trackPromoCode** - Track coupon/promo code usage
|
||||
```typescript
|
||||
trackPromoCode(
|
||||
"SAVE10",
|
||||
578, // discount amount
|
||||
true // success
|
||||
);
|
||||
```
|
||||
|
||||
### 7. Wishlist
|
||||
|
||||
**trackWishlistAction** - Track wishlist interactions
|
||||
```typescript
|
||||
// Add to wishlist
|
||||
trackWishlistAction("add", "prod_123", "Anti-Age Serum");
|
||||
|
||||
// Remove from wishlist
|
||||
trackWishlistAction("remove", "prod_123", "Anti-Age Serum");
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Identification
|
||||
|
||||
### identifyUser
|
||||
|
||||
Identify users across sessions:
|
||||
```typescript
|
||||
identifyUser({
|
||||
profileId: "user_uuid",
|
||||
email: "customer@example.com",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
phone: "+38161123456",
|
||||
properties: {
|
||||
signup_date: "2024-03-01",
|
||||
preferred_language: "sr",
|
||||
total_orders: 5,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### setUserProperties
|
||||
|
||||
Set global user properties:
|
||||
```typescript
|
||||
setUserProperties({
|
||||
loyalty_tier: "gold",
|
||||
last_purchase_date: "2024-03-25",
|
||||
preferred_category: "serums",
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Session/Screen Tracking
|
||||
|
||||
### trackScreenView
|
||||
|
||||
Track page views manually:
|
||||
```typescript
|
||||
trackScreenView(
|
||||
"/products/anti-age-serum",
|
||||
"Manoon Anti-Age Serum - ManoonOils"
|
||||
);
|
||||
```
|
||||
|
||||
### trackSessionStart
|
||||
|
||||
Track new sessions:
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
trackSessionStart();
|
||||
}, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Wrap in try-catch
|
||||
Tracking should never break the user experience:
|
||||
```typescript
|
||||
try {
|
||||
trackAddToCart(product);
|
||||
} catch (e) {
|
||||
console.error("Tracking failed:", e);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Use Consistent Naming
|
||||
- Use snake_case for property names
|
||||
- Be consistent with event names
|
||||
- Use past tense for events (e.g., `product_viewed` not `view_product`)
|
||||
|
||||
### 3. Include Context
|
||||
Always include relevant context:
|
||||
```typescript
|
||||
// Good
|
||||
trackCTAClick("Shop Now", "hero_section", "/products");
|
||||
|
||||
// Less useful
|
||||
trackCTAClick("button_click");
|
||||
```
|
||||
|
||||
### 4. Track Revenue Properly
|
||||
Always use `trackOrderCompleted` for final purchases - it includes both event tracking and revenue tracking.
|
||||
|
||||
### 5. Increment/Decrement Counters
|
||||
Use increment/decrement for user-level metrics:
|
||||
- Total orders: `op.increment({ total_orders: 1 })`
|
||||
- Wishlist items: `op.increment({ wishlist_items: 1 })`
|
||||
- Product views: `op.increment({ product_views: 1 })`
|
||||
|
||||
---
|
||||
|
||||
## Analytics Dashboard Views
|
||||
|
||||
With this implementation, you can create OpenPanel dashboards for:
|
||||
|
||||
1. **E-commerce Funnel**
|
||||
- Product views → Add to cart → Checkout started → Order completed
|
||||
- Conversion rates at each step
|
||||
- Cart abandonment rate
|
||||
|
||||
2. **Revenue Analytics**
|
||||
- Total revenue by period
|
||||
- Revenue by payment method
|
||||
- Revenue by product category
|
||||
- Average order value
|
||||
|
||||
3. **User Behavior**
|
||||
- Most viewed products
|
||||
- Popular search terms
|
||||
- CTA click rates
|
||||
- Time to purchase
|
||||
|
||||
4. **User Properties**
|
||||
- User segments by total orders
|
||||
- Repeat customers
|
||||
- Newsletter subscribers
|
||||
- Wishlist users
|
||||
|
||||
---
|
||||
|
||||
## Debugging
|
||||
|
||||
Check browser console for tracking logs. All tracking functions log to console in development mode.
|
||||
|
||||
OpenPanel dashboard: https://op.nodecrew.me
|
||||
@@ -13,13 +13,13 @@ import { saleorClient } from "@/lib/saleor/client";
|
||||
import { useAnalytics } from "@/lib/analytics";
|
||||
import {
|
||||
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||
ORDER_CONFIRM,
|
||||
} from "@/lib/saleor/mutations/Checkout";
|
||||
import { PaymentSection } from "./components/PaymentSection";
|
||||
import { DEFAULT_PAYMENT_METHOD } from "@/lib/config/paymentMethods";
|
||||
import { GET_CHECKOUT_BY_ID } from "@/lib/saleor/queries/Checkout";
|
||||
import type { Checkout } from "@/types/saleor";
|
||||
import { createCheckoutService, type Address } from "@/lib/services/checkoutService";
|
||||
import { useShippingMethodSelector } from "@/lib/hooks/useShippingMethodSelector";
|
||||
|
||||
interface ShippingAddressUpdateResponse {
|
||||
checkoutShippingAddressUpdate?: {
|
||||
@@ -32,6 +32,8 @@ interface CheckoutQueryResponse {
|
||||
checkout?: Checkout;
|
||||
}
|
||||
|
||||
|
||||
|
||||
interface ShippingMethod {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -93,8 +95,16 @@ export default function CheckoutPage() {
|
||||
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
|
||||
const [isLoadingShipping, setIsLoadingShipping] = useState(false);
|
||||
|
||||
// Hook to manage shipping method selection (both manual and auto)
|
||||
const { selectShippingMethodWithApi } = useShippingMethodSelector({
|
||||
checkoutId: checkout?.id ?? null,
|
||||
onSelect: setSelectedShippingMethod,
|
||||
onRefresh: refreshCheckout,
|
||||
});
|
||||
|
||||
const lines = getLines();
|
||||
const total = getTotal();
|
||||
// Use checkout.totalPrice directly for reactive updates when shipping method changes
|
||||
const total = checkout?.totalPrice?.gross?.amount || getTotal();
|
||||
|
||||
// Debounced shipping method fetching
|
||||
useEffect(() => {
|
||||
@@ -151,7 +161,9 @@ export default function CheckoutPage() {
|
||||
|
||||
// Auto-select first method if none selected
|
||||
if (availableMethods.length > 0 && !selectedShippingMethod) {
|
||||
setSelectedShippingMethod(availableMethods[0].id);
|
||||
const firstMethodId = availableMethods[0].id;
|
||||
// Use the hook to both update UI and call API
|
||||
await selectShippingMethodWithApi(firstMethodId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error fetching shipping methods:", err);
|
||||
@@ -183,6 +195,7 @@ export default function CheckoutPage() {
|
||||
name: line.variant.product.name,
|
||||
quantity: line.quantity,
|
||||
price: line.variant.pricing?.price?.gross?.amount || 0,
|
||||
currency: line.variant.pricing?.price?.gross?.currency || "RSD",
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -210,6 +223,10 @@ export default function CheckoutPage() {
|
||||
setShippingAddress((prev) => ({ ...prev, email: value }));
|
||||
};
|
||||
|
||||
const handleShippingMethodSelect = async (methodId: string) => {
|
||||
await selectShippingMethodWithApi(methodId);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -307,27 +324,10 @@ export default function CheckoutPage() {
|
||||
setOrderNumber(result.order.number);
|
||||
setOrderComplete(true);
|
||||
|
||||
// Auto-confirm the order
|
||||
try {
|
||||
console.log("Auto-confirming order:", result.order.id);
|
||||
await saleorClient.mutate({
|
||||
mutation: ORDER_CONFIRM,
|
||||
variables: {
|
||||
orderId: result.order.id,
|
||||
},
|
||||
});
|
||||
console.log("Order confirmed successfully");
|
||||
} catch (confirmError) {
|
||||
console.error("Failed to auto-confirm order:", confirmError);
|
||||
// Don't fail the checkout if confirmation fails
|
||||
}
|
||||
|
||||
// Clear the checkout/cart from the store
|
||||
clearCheckout();
|
||||
|
||||
// Track order completion
|
||||
// Track order completion BEFORE clearing checkout
|
||||
const lines = getLines();
|
||||
const total = getTotal();
|
||||
console.log("[Checkout] Order total before tracking:", total, "RSD");
|
||||
trackOrderCompleted({
|
||||
order_id: checkout.id,
|
||||
order_number: result.order.number,
|
||||
@@ -338,6 +338,9 @@ export default function CheckoutPage() {
|
||||
customer_email: shippingAddress.email,
|
||||
});
|
||||
|
||||
// Clear the checkout/cart from the store
|
||||
clearCheckout();
|
||||
|
||||
// Identify the user
|
||||
identifyUser({
|
||||
profileId: shippingAddress.email,
|
||||
@@ -590,7 +593,7 @@ export default function CheckoutPage() {
|
||||
name="shippingMethod"
|
||||
value={method.id}
|
||||
checked={selectedShippingMethod === method.id}
|
||||
onChange={(e) => setSelectedShippingMethod(e.target.value)}
|
||||
onChange={(e) => handleShippingMethodSelect(e.target.value)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="font-medium">{method.name}</span>
|
||||
|
||||
62
src/app/api/analytics/track-order/route.ts
Normal file
62
src/app/api/analytics/track-order/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { trackOrderCompletedServer, trackServerEvent } from "@/lib/analytics-server";
|
||||
|
||||
/**
|
||||
* POST /api/analytics/track-order
|
||||
*
|
||||
* Server-side order tracking endpoint
|
||||
* Called from client after successful order completion
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const {
|
||||
orderId,
|
||||
orderNumber,
|
||||
total,
|
||||
currency,
|
||||
itemCount,
|
||||
customerEmail,
|
||||
paymentMethod,
|
||||
shippingCost,
|
||||
couponCode,
|
||||
} = body;
|
||||
|
||||
// Validate required fields
|
||||
if (!orderId || !orderNumber || total === undefined) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required fields" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Track server-side
|
||||
const result = await trackOrderCompletedServer({
|
||||
orderId,
|
||||
orderNumber,
|
||||
total,
|
||||
currency: currency || "RSD",
|
||||
itemCount: itemCount || 0,
|
||||
customerEmail,
|
||||
paymentMethod,
|
||||
shippingCost,
|
||||
couponCode,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return NextResponse.json({ success: true });
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: result.error },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[API Analytics] Error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { motion } from "framer-motion";
|
||||
|
||||
export default function TickerBar() {
|
||||
const items = [
|
||||
"Free shipping on orders over 3000 RSD",
|
||||
"Free shipping on orders over 10000 RSD",
|
||||
"Natural ingredients",
|
||||
"Cruelty-free",
|
||||
"Handmade with love",
|
||||
|
||||
@@ -55,14 +55,14 @@ export default function Header({ locale: propLocale = "sr" }: HeaderProps) {
|
||||
setLangDropdownOpen(false);
|
||||
};
|
||||
|
||||
// Set language code first, then initialize checkout
|
||||
// Set language code - checkout initializes lazily when cart is opened
|
||||
useEffect(() => {
|
||||
if (locale) {
|
||||
setLanguageCode(locale);
|
||||
// Initialize checkout after language code is set
|
||||
initCheckout();
|
||||
// Checkout will initialize lazily when user adds to cart or opens cart drawer
|
||||
// This prevents blocking page render with unnecessary API calls
|
||||
}
|
||||
}, [locale, setLanguageCode, initCheckout]);
|
||||
}, [locale, setLanguageCode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"ctaButton": "Mein Haar & Haut transformieren",
|
||||
"learnStory": "Unsere Geschichte entdecken",
|
||||
"moneyBack": "30-Tage Geld-zurück",
|
||||
"freeShipping": "Kostenloser Versand über 3.000 RSD",
|
||||
"freeShipping": "Kostenloser Versand über 10.000 RSD",
|
||||
"crueltyFree": "Tierversuchsfrei"
|
||||
},
|
||||
"collection": "Unsere Kollektion",
|
||||
@@ -117,7 +117,7 @@
|
||||
"email": "E-Mail",
|
||||
"emailReply": "Wir antworten innerhalb von 24 Stunden",
|
||||
"shippingTitle": "Versand",
|
||||
"freeShipping": "Kostenloser Versand über 3.000 RSD",
|
||||
"freeShipping": "Kostenloser Versand über 10.000 RSD",
|
||||
"deliveryTime": "Geliefert innerhalb von 2-5 Werktagen",
|
||||
"location": "Standort",
|
||||
"locationDesc": "Serbien",
|
||||
@@ -220,7 +220,7 @@
|
||||
"naturalIngredients": "Natürliche Inhaltsstoffe",
|
||||
"noAdditives": "Keine Zusatzstoffe",
|
||||
"freeShipping": "Kostenloser Versand",
|
||||
"ordersOver": "Bestellungen über 3.000 RSD"
|
||||
"ordersOver": "Bestellungen über 10.000 RSD"
|
||||
},
|
||||
"ProblemSection": {
|
||||
"title": "Das Problem",
|
||||
@@ -295,7 +295,7 @@
|
||||
"qty": "Menge",
|
||||
"adding": "Wird hinzugefügt...",
|
||||
"transformHairSkin": "Mein Haar & Haut transformieren",
|
||||
"freeShipping": "Kostenloser Versand bei Bestellungen über 3.000 RSD",
|
||||
"freeShipping": "Kostenloser Versand bei Bestellungen über 10.000 RSD",
|
||||
"guarantee": "30-Tage-Garantie",
|
||||
"secureCheckout": "Sicheres Bezahlen",
|
||||
"easyReturns": "Einfache Rückgabe",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"ctaButton": "Transform My Hair & Skin",
|
||||
"learnStory": "Learn Our Story",
|
||||
"moneyBack": "30-Day Money Back",
|
||||
"freeShipping": "Free Shipping Over 3,000 RSD",
|
||||
"freeShipping": "Free Shipping Over 10,000 RSD",
|
||||
"crueltyFree": "Cruelty Free"
|
||||
},
|
||||
"collection": "Our Collection",
|
||||
@@ -229,7 +229,7 @@
|
||||
"naturalIngredients": "Natural Ingredients",
|
||||
"noAdditives": "No additives",
|
||||
"freeShipping": "Free Shipping",
|
||||
"ordersOver": "Orders over 3,000 RSD"
|
||||
"ordersOver": "Orders over 10,000 RSD"
|
||||
},
|
||||
"ProblemSection": {
|
||||
"title": "The Problem",
|
||||
@@ -324,7 +324,7 @@
|
||||
"qty": "Qty",
|
||||
"adding": "Adding...",
|
||||
"transformHairSkin": "Transform My Hair & Skin",
|
||||
"freeShipping": "Free shipping on orders over 3,000 RSD",
|
||||
"freeShipping": "Free shipping on orders over 10,000 RSD",
|
||||
"guarantee": "30-Day Guarantee",
|
||||
"secureCheckout": "Secure Checkout",
|
||||
"easyReturns": "Easy Returns",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"ctaButton": "Transformer Mes Cheveux & Ma Peau",
|
||||
"learnStory": "Découvrir Notre Histoire",
|
||||
"moneyBack": "30 Jours Satisfait",
|
||||
"freeShipping": "Livraison Gratuite +3.000 RSD",
|
||||
"freeShipping": "Livraison Gratuite +10.000 RSD",
|
||||
"crueltyFree": "Cruelty Free"
|
||||
},
|
||||
"collection": "Notre Collection",
|
||||
@@ -117,7 +117,7 @@
|
||||
"email": "Email",
|
||||
"emailReply": "Nous répondons dans les 24 heures",
|
||||
"shippingTitle": "Livraison",
|
||||
"freeShipping": "Livraison gratuite +3.000 RSD",
|
||||
"freeShipping": "Livraison gratuite +10.000 RSD",
|
||||
"deliveryTime": "Livré dans 2-5 jours ouvrables",
|
||||
"location": "Localisation",
|
||||
"locationDesc": "Serbie",
|
||||
@@ -220,7 +220,7 @@
|
||||
"naturalIngredients": "Ingrédients Naturels",
|
||||
"noAdditives": "Sans Additifs",
|
||||
"freeShipping": "Livraison Gratuite",
|
||||
"ordersOver": "Commandes +3.000 RSD"
|
||||
"ordersOver": "Commandes +10.000 RSD"
|
||||
},
|
||||
"ProblemSection": {
|
||||
"title": "Le Problème",
|
||||
@@ -295,7 +295,7 @@
|
||||
"qty": "Qté",
|
||||
"adding": "Ajout en cours...",
|
||||
"transformHairSkin": "Transformer Mes Cheveux & Ma Peau",
|
||||
"freeShipping": "Livraison gratuite sur les commandes de +3.000 RSD",
|
||||
"freeShipping": "Livraison gratuite sur les commandes de +10.000 RSD",
|
||||
"guarantee": "Garantie 30 Jours",
|
||||
"secureCheckout": "Paiement Sécurisé",
|
||||
"easyReturns": "Retours Faciles",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"ctaButton": "Transformiši moju kosu i kožu",
|
||||
"learnStory": "Saznaj našu priču",
|
||||
"moneyBack": "Povrat novca 30 dana",
|
||||
"freeShipping": "Besplatna dostava preko 3.000 RSD",
|
||||
"freeShipping": "Besplatna dostava preko 10.000 RSD",
|
||||
"crueltyFree": "Bez okrutnosti"
|
||||
},
|
||||
"collection": "Naša kolekcija",
|
||||
@@ -108,7 +108,7 @@
|
||||
"email": "Email",
|
||||
"emailReply": "Odgovaramo u roku od 24 sata",
|
||||
"shippingTitle": "Dostava",
|
||||
"freeShipping": "Besplatna dostava preko 3.000 RSD",
|
||||
"freeShipping": "Besplatna dostava preko 10.000 RSD",
|
||||
"deliveryTime": "Isporučeno u roku od 2-5 radnih dana",
|
||||
"location": "Lokacija",
|
||||
"locationDesc": "Srbija",
|
||||
@@ -229,7 +229,7 @@
|
||||
"naturalIngredients": "Prirodni sastojci",
|
||||
"noAdditives": "Bez aditiva",
|
||||
"freeShipping": "Besplatna dostava",
|
||||
"ordersOver": "Porudžbine preko 3.000 RSD"
|
||||
"ordersOver": "Porudžbine preko 10.000 RSD"
|
||||
},
|
||||
"ProblemSection": {
|
||||
"title": "Problem",
|
||||
@@ -324,7 +324,7 @@
|
||||
"qty": "Kol",
|
||||
"adding": "Dodavanje...",
|
||||
"transformHairSkin": "Transformiši kosu i kožu",
|
||||
"freeShipping": "Besplatna dostava za porudžbine preko 3.000 RSD",
|
||||
"freeShipping": "Besplatna dostava za porudžbine preko 10.000 RSD",
|
||||
"guarantee": "30-dnevna garancija",
|
||||
"secureCheckout": "Sigurno plaćanje",
|
||||
"easyReturns": "Lak povrat",
|
||||
|
||||
98
src/lib/analytics-server.ts
Normal file
98
src/lib/analytics-server.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
"use server";
|
||||
|
||||
import { OpenPanel } from "@openpanel/nextjs";
|
||||
|
||||
// Server-side OpenPanel instance
|
||||
const op = new OpenPanel({
|
||||
clientId: process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID || "",
|
||||
clientSecret: process.env.OPENPANEL_CLIENT_SECRET || "",
|
||||
apiUrl: process.env.OPENPANEL_API_URL || "https://op.nodecrew.me/api",
|
||||
});
|
||||
|
||||
export interface ServerOrderData {
|
||||
orderId: string;
|
||||
orderNumber: string;
|
||||
total: number;
|
||||
currency: string;
|
||||
itemCount: number;
|
||||
customerEmail?: string;
|
||||
paymentMethod?: string;
|
||||
shippingCost?: number;
|
||||
couponCode?: string;
|
||||
}
|
||||
|
||||
export interface ServerEventData {
|
||||
event: string;
|
||||
properties?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side analytics tracking
|
||||
* Called from API routes or Server Components
|
||||
*/
|
||||
export async function trackOrderCompletedServer(data: ServerOrderData) {
|
||||
try {
|
||||
console.log("[Server Analytics] Tracking order:", data.orderNumber, "Total:", data.total);
|
||||
|
||||
// Track order event
|
||||
await op.track("order_completed", {
|
||||
order_id: data.orderId,
|
||||
order_number: data.orderNumber,
|
||||
total: data.total,
|
||||
currency: data.currency,
|
||||
item_count: data.itemCount,
|
||||
customer_email: data.customerEmail,
|
||||
payment_method: data.paymentMethod,
|
||||
shipping_cost: data.shippingCost,
|
||||
coupon_code: data.couponCode,
|
||||
source: "server",
|
||||
});
|
||||
|
||||
// Track revenue (this is the important part!)
|
||||
await op.revenue(data.total, {
|
||||
currency: data.currency,
|
||||
transaction_id: data.orderNumber,
|
||||
order_id: data.orderId,
|
||||
source: "server",
|
||||
});
|
||||
|
||||
console.log("[Server Analytics] Order tracked successfully");
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("[Server Analytics] Failed to track order:", error);
|
||||
// Don't throw - analytics shouldn't break the app
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track any server-side event
|
||||
*/
|
||||
export async function trackServerEvent(data: ServerEventData) {
|
||||
try {
|
||||
await op.track(data.event, {
|
||||
...data.properties,
|
||||
source: "server",
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("[Server Analytics] Event tracking failed:", error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify user server-side
|
||||
*/
|
||||
export async function identifyUserServer(profileId: string, properties?: Record<string, any>) {
|
||||
try {
|
||||
await op.identify({
|
||||
profileId,
|
||||
...properties,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("[Server Analytics] Identify failed:", error);
|
||||
return { success: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,7 @@ import { useCallback } from "react";
|
||||
export function useAnalytics() {
|
||||
const op = useOpenPanel();
|
||||
|
||||
// Page views are tracked automatically by OpenPanelComponent
|
||||
// but we can track specific events manually
|
||||
|
||||
// Client-side tracking for user behavior
|
||||
const trackProductView = useCallback((product: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -16,13 +14,18 @@ export function useAnalytics() {
|
||||
currency: string;
|
||||
category?: string;
|
||||
}) => {
|
||||
op.track("product_viewed", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
category: product.category,
|
||||
});
|
||||
try {
|
||||
op.track("product_viewed", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
category: product.category,
|
||||
source: "client",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Product view error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
|
||||
const trackAddToCart = useCallback((product: {
|
||||
@@ -33,14 +36,19 @@ export function useAnalytics() {
|
||||
quantity: number;
|
||||
variant?: string;
|
||||
}) => {
|
||||
op.track("add_to_cart", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
quantity: product.quantity,
|
||||
variant: product.variant,
|
||||
});
|
||||
try {
|
||||
op.track("add_to_cart", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
price: product.price,
|
||||
currency: product.currency,
|
||||
quantity: product.quantity,
|
||||
variant: product.variant,
|
||||
source: "client",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Add to cart error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
|
||||
const trackRemoveFromCart = useCallback((product: {
|
||||
@@ -48,11 +56,16 @@ export function useAnalytics() {
|
||||
name: string;
|
||||
quantity: number;
|
||||
}) => {
|
||||
op.track("remove_from_cart", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
quantity: product.quantity,
|
||||
});
|
||||
try {
|
||||
op.track("remove_from_cart", {
|
||||
product_id: product.id,
|
||||
product_name: product.name,
|
||||
quantity: product.quantity,
|
||||
source: "client",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Remove from cart error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
|
||||
const trackCheckoutStarted = useCallback((cart: {
|
||||
@@ -66,22 +79,37 @@ export function useAnalytics() {
|
||||
price: number;
|
||||
}>;
|
||||
}) => {
|
||||
op.track("checkout_started", {
|
||||
cart_total: cart.total,
|
||||
currency: cart.currency,
|
||||
item_count: cart.item_count,
|
||||
items: cart.items,
|
||||
});
|
||||
try {
|
||||
op.track("checkout_started", {
|
||||
cart_total: cart.total,
|
||||
currency: cart.currency,
|
||||
item_count: cart.item_count,
|
||||
items: cart.items,
|
||||
source: "client",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Checkout started error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
|
||||
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
|
||||
op.track("checkout_step", {
|
||||
step,
|
||||
...data,
|
||||
});
|
||||
try {
|
||||
op.track("checkout_step", {
|
||||
step,
|
||||
...data,
|
||||
source: "client",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Checkout step error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
|
||||
const trackOrderCompleted = useCallback((order: {
|
||||
/**
|
||||
* DUAL TRACKING: Order completion
|
||||
* 1. Track client-side (immediate, captures user session)
|
||||
* 2. Call server-side API (reliable, can't be blocked)
|
||||
*/
|
||||
const trackOrderCompleted = useCallback(async (order: {
|
||||
order_id: string;
|
||||
order_number: string;
|
||||
total: number;
|
||||
@@ -89,37 +117,86 @@ export function useAnalytics() {
|
||||
item_count: number;
|
||||
shipping_cost?: number;
|
||||
customer_email?: string;
|
||||
payment_method?: string;
|
||||
}) => {
|
||||
op.track("order_completed", {
|
||||
order_id: order.order_id,
|
||||
order_number: order.order_number,
|
||||
total: order.total,
|
||||
currency: order.currency,
|
||||
item_count: order.item_count,
|
||||
shipping_cost: order.shipping_cost,
|
||||
customer_email: order.customer_email,
|
||||
});
|
||||
console.log("[Dual Analytics] Tracking order:", order.order_number, "Total:", order.total);
|
||||
|
||||
// Also track revenue for analytics
|
||||
op.track("purchase", {
|
||||
transaction_id: order.order_number,
|
||||
value: order.total,
|
||||
currency: order.currency,
|
||||
});
|
||||
// CLIENT-SIDE: Track immediately for user session data
|
||||
try {
|
||||
op.track("order_completed", {
|
||||
order_id: order.order_id,
|
||||
order_number: order.order_number,
|
||||
total: order.total,
|
||||
currency: order.currency,
|
||||
item_count: order.item_count,
|
||||
shipping_cost: order.shipping_cost,
|
||||
customer_email: order.customer_email,
|
||||
payment_method: order.payment_method,
|
||||
source: "client",
|
||||
});
|
||||
|
||||
op.revenue(order.total, {
|
||||
currency: order.currency,
|
||||
transaction_id: order.order_number,
|
||||
source: "client",
|
||||
});
|
||||
|
||||
console.log("[Client Analytics] Order tracked");
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Order tracking error:", e);
|
||||
}
|
||||
|
||||
// SERVER-SIDE: Call API for reliable tracking
|
||||
try {
|
||||
console.log("[Server Analytics] Calling server-side tracking API...");
|
||||
|
||||
const response = await fetch("/api/analytics/track-order", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
orderId: order.order_id,
|
||||
orderNumber: order.order_number,
|
||||
total: order.total,
|
||||
currency: order.currency,
|
||||
itemCount: order.item_count,
|
||||
customerEmail: order.customer_email,
|
||||
paymentMethod: order.payment_method,
|
||||
shippingCost: order.shipping_cost,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log("[Server Analytics] Order tracked successfully");
|
||||
} else {
|
||||
console.error("[Server Analytics] Failed:", await response.text());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Server Analytics] API call failed:", e);
|
||||
}
|
||||
}, [op]);
|
||||
|
||||
const trackSearch = useCallback((query: string, results_count: number) => {
|
||||
op.track("search", {
|
||||
query,
|
||||
results_count,
|
||||
});
|
||||
try {
|
||||
op.track("search", {
|
||||
query,
|
||||
results_count,
|
||||
source: "client",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Search error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
|
||||
const trackExternalLink = useCallback((url: string, label?: string) => {
|
||||
op.track("external_link_click", {
|
||||
url,
|
||||
label,
|
||||
});
|
||||
try {
|
||||
op.track("external_link_click", {
|
||||
url,
|
||||
label,
|
||||
source: "client",
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] External link error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
|
||||
const identifyUser = useCallback((user: {
|
||||
@@ -127,15 +204,17 @@ export function useAnalytics() {
|
||||
email?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}) => {
|
||||
op.identify({
|
||||
profileId: user.profileId,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
properties: user.properties,
|
||||
});
|
||||
try {
|
||||
op.identify({
|
||||
profileId: user.profileId,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[Client Analytics] Identify error:", e);
|
||||
}
|
||||
}, [op]);
|
||||
|
||||
return {
|
||||
|
||||
73
src/lib/hooks/useShippingMethodSelector.ts
Normal file
73
src/lib/hooks/useShippingMethodSelector.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { createCheckoutService } from "@/lib/services/checkoutService";
|
||||
|
||||
interface UseShippingMethodSelectorOptions {
|
||||
checkoutId: string | null;
|
||||
onSelect: (methodId: string) => void;
|
||||
onRefresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface UseShippingMethodSelectorResult {
|
||||
selectShippingMethod: (methodId: string) => Promise<void>;
|
||||
selectShippingMethodWithApi: (methodId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage shipping method selection
|
||||
* Encapsulates both UI state update and API communication
|
||||
* Used for both manual selection (user click) and auto-selection (default method)
|
||||
*/
|
||||
export function useShippingMethodSelector(
|
||||
options: UseShippingMethodSelectorOptions
|
||||
): UseShippingMethodSelectorResult {
|
||||
const { checkoutId, onSelect, onRefresh } = options;
|
||||
|
||||
/**
|
||||
* Updates UI state only (for initial/pre-selection)
|
||||
*/
|
||||
const selectShippingMethod = useCallback(
|
||||
async (methodId: string) => {
|
||||
onSelect(methodId);
|
||||
},
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates UI state AND calls Saleor API
|
||||
* Use this when user manually selects OR when auto-selecting the default
|
||||
*/
|
||||
const selectShippingMethodWithApi = useCallback(
|
||||
async (methodId: string) => {
|
||||
if (!checkoutId) {
|
||||
console.warn("[selectShippingMethodWithApi] No checkoutId provided");
|
||||
return;
|
||||
}
|
||||
|
||||
// Update UI immediately for responsiveness
|
||||
onSelect(methodId);
|
||||
|
||||
// Call API through CheckoutService
|
||||
const checkoutService = createCheckoutService(checkoutId);
|
||||
const result = await checkoutService.updateShippingMethod(methodId);
|
||||
|
||||
if (result.success) {
|
||||
// Refresh checkout to get updated totals including shipping
|
||||
await onRefresh();
|
||||
} else {
|
||||
console.error(
|
||||
"[selectShippingMethodWithApi] Failed to update shipping method:",
|
||||
result.error
|
||||
);
|
||||
// Could add error handling/rollback here
|
||||
}
|
||||
},
|
||||
[checkoutId, onSelect, onRefresh]
|
||||
);
|
||||
|
||||
return {
|
||||
selectShippingMethod,
|
||||
selectShippingMethodWithApi,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user