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 { useAnalytics } from "@/lib/analytics";
|
||||||
import {
|
import {
|
||||||
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||||
ORDER_CONFIRM,
|
|
||||||
} from "@/lib/saleor/mutations/Checkout";
|
} from "@/lib/saleor/mutations/Checkout";
|
||||||
import { PaymentSection } from "./components/PaymentSection";
|
import { PaymentSection } from "./components/PaymentSection";
|
||||||
import { DEFAULT_PAYMENT_METHOD } from "@/lib/config/paymentMethods";
|
import { DEFAULT_PAYMENT_METHOD } from "@/lib/config/paymentMethods";
|
||||||
import { GET_CHECKOUT_BY_ID } from "@/lib/saleor/queries/Checkout";
|
import { GET_CHECKOUT_BY_ID } from "@/lib/saleor/queries/Checkout";
|
||||||
import type { Checkout } from "@/types/saleor";
|
import type { Checkout } from "@/types/saleor";
|
||||||
import { createCheckoutService, type Address } from "@/lib/services/checkoutService";
|
import { createCheckoutService, type Address } from "@/lib/services/checkoutService";
|
||||||
|
import { useShippingMethodSelector } from "@/lib/hooks/useShippingMethodSelector";
|
||||||
|
|
||||||
interface ShippingAddressUpdateResponse {
|
interface ShippingAddressUpdateResponse {
|
||||||
checkoutShippingAddressUpdate?: {
|
checkoutShippingAddressUpdate?: {
|
||||||
@@ -32,6 +32,8 @@ interface CheckoutQueryResponse {
|
|||||||
checkout?: Checkout;
|
checkout?: Checkout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
interface ShippingMethod {
|
interface ShippingMethod {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -93,8 +95,16 @@ export default function CheckoutPage() {
|
|||||||
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
|
const [selectedShippingMethod, setSelectedShippingMethod] = useState<string>("");
|
||||||
const [isLoadingShipping, setIsLoadingShipping] = useState(false);
|
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 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
|
// Debounced shipping method fetching
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -151,7 +161,9 @@ export default function CheckoutPage() {
|
|||||||
|
|
||||||
// Auto-select first method if none selected
|
// Auto-select first method if none selected
|
||||||
if (availableMethods.length > 0 && !selectedShippingMethod) {
|
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) {
|
} catch (err) {
|
||||||
console.error("Error fetching shipping methods:", err);
|
console.error("Error fetching shipping methods:", err);
|
||||||
@@ -183,6 +195,7 @@ export default function CheckoutPage() {
|
|||||||
name: line.variant.product.name,
|
name: line.variant.product.name,
|
||||||
quantity: line.quantity,
|
quantity: line.quantity,
|
||||||
price: line.variant.pricing?.price?.gross?.amount || 0,
|
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 }));
|
setShippingAddress((prev) => ({ ...prev, email: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleShippingMethodSelect = async (methodId: string) => {
|
||||||
|
await selectShippingMethodWithApi(methodId);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -307,27 +324,10 @@ export default function CheckoutPage() {
|
|||||||
setOrderNumber(result.order.number);
|
setOrderNumber(result.order.number);
|
||||||
setOrderComplete(true);
|
setOrderComplete(true);
|
||||||
|
|
||||||
// Auto-confirm the order
|
// Track order completion BEFORE clearing checkout
|
||||||
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
|
|
||||||
const lines = getLines();
|
const lines = getLines();
|
||||||
const total = getTotal();
|
const total = getTotal();
|
||||||
|
console.log("[Checkout] Order total before tracking:", total, "RSD");
|
||||||
trackOrderCompleted({
|
trackOrderCompleted({
|
||||||
order_id: checkout.id,
|
order_id: checkout.id,
|
||||||
order_number: result.order.number,
|
order_number: result.order.number,
|
||||||
@@ -338,6 +338,9 @@ export default function CheckoutPage() {
|
|||||||
customer_email: shippingAddress.email,
|
customer_email: shippingAddress.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Clear the checkout/cart from the store
|
||||||
|
clearCheckout();
|
||||||
|
|
||||||
// Identify the user
|
// Identify the user
|
||||||
identifyUser({
|
identifyUser({
|
||||||
profileId: shippingAddress.email,
|
profileId: shippingAddress.email,
|
||||||
@@ -590,7 +593,7 @@ export default function CheckoutPage() {
|
|||||||
name="shippingMethod"
|
name="shippingMethod"
|
||||||
value={method.id}
|
value={method.id}
|
||||||
checked={selectedShippingMethod === method.id}
|
checked={selectedShippingMethod === method.id}
|
||||||
onChange={(e) => setSelectedShippingMethod(e.target.value)}
|
onChange={(e) => handleShippingMethodSelect(e.target.value)}
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
<span className="font-medium">{method.name}</span>
|
<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() {
|
export default function TickerBar() {
|
||||||
const items = [
|
const items = [
|
||||||
"Free shipping on orders over 3000 RSD",
|
"Free shipping on orders over 10000 RSD",
|
||||||
"Natural ingredients",
|
"Natural ingredients",
|
||||||
"Cruelty-free",
|
"Cruelty-free",
|
||||||
"Handmade with love",
|
"Handmade with love",
|
||||||
|
|||||||
@@ -55,14 +55,14 @@ export default function Header({ locale: propLocale = "sr" }: HeaderProps) {
|
|||||||
setLangDropdownOpen(false);
|
setLangDropdownOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Set language code first, then initialize checkout
|
// Set language code - checkout initializes lazily when cart is opened
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (locale) {
|
if (locale) {
|
||||||
setLanguageCode(locale);
|
setLanguageCode(locale);
|
||||||
// Initialize checkout after language code is set
|
// Checkout will initialize lazily when user adds to cart or opens cart drawer
|
||||||
initCheckout();
|
// This prevents blocking page render with unnecessary API calls
|
||||||
}
|
}
|
||||||
}, [locale, setLanguageCode, initCheckout]);
|
}, [locale, setLanguageCode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"ctaButton": "Mein Haar & Haut transformieren",
|
"ctaButton": "Mein Haar & Haut transformieren",
|
||||||
"learnStory": "Unsere Geschichte entdecken",
|
"learnStory": "Unsere Geschichte entdecken",
|
||||||
"moneyBack": "30-Tage Geld-zurück",
|
"moneyBack": "30-Tage Geld-zurück",
|
||||||
"freeShipping": "Kostenloser Versand über 3.000 RSD",
|
"freeShipping": "Kostenloser Versand über 10.000 RSD",
|
||||||
"crueltyFree": "Tierversuchsfrei"
|
"crueltyFree": "Tierversuchsfrei"
|
||||||
},
|
},
|
||||||
"collection": "Unsere Kollektion",
|
"collection": "Unsere Kollektion",
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
"email": "E-Mail",
|
"email": "E-Mail",
|
||||||
"emailReply": "Wir antworten innerhalb von 24 Stunden",
|
"emailReply": "Wir antworten innerhalb von 24 Stunden",
|
||||||
"shippingTitle": "Versand",
|
"shippingTitle": "Versand",
|
||||||
"freeShipping": "Kostenloser Versand über 3.000 RSD",
|
"freeShipping": "Kostenloser Versand über 10.000 RSD",
|
||||||
"deliveryTime": "Geliefert innerhalb von 2-5 Werktagen",
|
"deliveryTime": "Geliefert innerhalb von 2-5 Werktagen",
|
||||||
"location": "Standort",
|
"location": "Standort",
|
||||||
"locationDesc": "Serbien",
|
"locationDesc": "Serbien",
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
"naturalIngredients": "Natürliche Inhaltsstoffe",
|
"naturalIngredients": "Natürliche Inhaltsstoffe",
|
||||||
"noAdditives": "Keine Zusatzstoffe",
|
"noAdditives": "Keine Zusatzstoffe",
|
||||||
"freeShipping": "Kostenloser Versand",
|
"freeShipping": "Kostenloser Versand",
|
||||||
"ordersOver": "Bestellungen über 3.000 RSD"
|
"ordersOver": "Bestellungen über 10.000 RSD"
|
||||||
},
|
},
|
||||||
"ProblemSection": {
|
"ProblemSection": {
|
||||||
"title": "Das Problem",
|
"title": "Das Problem",
|
||||||
@@ -295,7 +295,7 @@
|
|||||||
"qty": "Menge",
|
"qty": "Menge",
|
||||||
"adding": "Wird hinzugefügt...",
|
"adding": "Wird hinzugefügt...",
|
||||||
"transformHairSkin": "Mein Haar & Haut transformieren",
|
"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",
|
"guarantee": "30-Tage-Garantie",
|
||||||
"secureCheckout": "Sicheres Bezahlen",
|
"secureCheckout": "Sicheres Bezahlen",
|
||||||
"easyReturns": "Einfache Rückgabe",
|
"easyReturns": "Einfache Rückgabe",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"ctaButton": "Transform My Hair & Skin",
|
"ctaButton": "Transform My Hair & Skin",
|
||||||
"learnStory": "Learn Our Story",
|
"learnStory": "Learn Our Story",
|
||||||
"moneyBack": "30-Day Money Back",
|
"moneyBack": "30-Day Money Back",
|
||||||
"freeShipping": "Free Shipping Over 3,000 RSD",
|
"freeShipping": "Free Shipping Over 10,000 RSD",
|
||||||
"crueltyFree": "Cruelty Free"
|
"crueltyFree": "Cruelty Free"
|
||||||
},
|
},
|
||||||
"collection": "Our Collection",
|
"collection": "Our Collection",
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
"naturalIngredients": "Natural Ingredients",
|
"naturalIngredients": "Natural Ingredients",
|
||||||
"noAdditives": "No additives",
|
"noAdditives": "No additives",
|
||||||
"freeShipping": "Free Shipping",
|
"freeShipping": "Free Shipping",
|
||||||
"ordersOver": "Orders over 3,000 RSD"
|
"ordersOver": "Orders over 10,000 RSD"
|
||||||
},
|
},
|
||||||
"ProblemSection": {
|
"ProblemSection": {
|
||||||
"title": "The Problem",
|
"title": "The Problem",
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
"qty": "Qty",
|
"qty": "Qty",
|
||||||
"adding": "Adding...",
|
"adding": "Adding...",
|
||||||
"transformHairSkin": "Transform My Hair & Skin",
|
"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",
|
"guarantee": "30-Day Guarantee",
|
||||||
"secureCheckout": "Secure Checkout",
|
"secureCheckout": "Secure Checkout",
|
||||||
"easyReturns": "Easy Returns",
|
"easyReturns": "Easy Returns",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"ctaButton": "Transformer Mes Cheveux & Ma Peau",
|
"ctaButton": "Transformer Mes Cheveux & Ma Peau",
|
||||||
"learnStory": "Découvrir Notre Histoire",
|
"learnStory": "Découvrir Notre Histoire",
|
||||||
"moneyBack": "30 Jours Satisfait",
|
"moneyBack": "30 Jours Satisfait",
|
||||||
"freeShipping": "Livraison Gratuite +3.000 RSD",
|
"freeShipping": "Livraison Gratuite +10.000 RSD",
|
||||||
"crueltyFree": "Cruelty Free"
|
"crueltyFree": "Cruelty Free"
|
||||||
},
|
},
|
||||||
"collection": "Notre Collection",
|
"collection": "Notre Collection",
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
"email": "Email",
|
"email": "Email",
|
||||||
"emailReply": "Nous répondons dans les 24 heures",
|
"emailReply": "Nous répondons dans les 24 heures",
|
||||||
"shippingTitle": "Livraison",
|
"shippingTitle": "Livraison",
|
||||||
"freeShipping": "Livraison gratuite +3.000 RSD",
|
"freeShipping": "Livraison gratuite +10.000 RSD",
|
||||||
"deliveryTime": "Livré dans 2-5 jours ouvrables",
|
"deliveryTime": "Livré dans 2-5 jours ouvrables",
|
||||||
"location": "Localisation",
|
"location": "Localisation",
|
||||||
"locationDesc": "Serbie",
|
"locationDesc": "Serbie",
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
"naturalIngredients": "Ingrédients Naturels",
|
"naturalIngredients": "Ingrédients Naturels",
|
||||||
"noAdditives": "Sans Additifs",
|
"noAdditives": "Sans Additifs",
|
||||||
"freeShipping": "Livraison Gratuite",
|
"freeShipping": "Livraison Gratuite",
|
||||||
"ordersOver": "Commandes +3.000 RSD"
|
"ordersOver": "Commandes +10.000 RSD"
|
||||||
},
|
},
|
||||||
"ProblemSection": {
|
"ProblemSection": {
|
||||||
"title": "Le Problème",
|
"title": "Le Problème",
|
||||||
@@ -295,7 +295,7 @@
|
|||||||
"qty": "Qté",
|
"qty": "Qté",
|
||||||
"adding": "Ajout en cours...",
|
"adding": "Ajout en cours...",
|
||||||
"transformHairSkin": "Transformer Mes Cheveux & Ma Peau",
|
"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",
|
"guarantee": "Garantie 30 Jours",
|
||||||
"secureCheckout": "Paiement Sécurisé",
|
"secureCheckout": "Paiement Sécurisé",
|
||||||
"easyReturns": "Retours Faciles",
|
"easyReturns": "Retours Faciles",
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"ctaButton": "Transformiši moju kosu i kožu",
|
"ctaButton": "Transformiši moju kosu i kožu",
|
||||||
"learnStory": "Saznaj našu priču",
|
"learnStory": "Saznaj našu priču",
|
||||||
"moneyBack": "Povrat novca 30 dana",
|
"moneyBack": "Povrat novca 30 dana",
|
||||||
"freeShipping": "Besplatna dostava preko 3.000 RSD",
|
"freeShipping": "Besplatna dostava preko 10.000 RSD",
|
||||||
"crueltyFree": "Bez okrutnosti"
|
"crueltyFree": "Bez okrutnosti"
|
||||||
},
|
},
|
||||||
"collection": "Naša kolekcija",
|
"collection": "Naša kolekcija",
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
"email": "Email",
|
"email": "Email",
|
||||||
"emailReply": "Odgovaramo u roku od 24 sata",
|
"emailReply": "Odgovaramo u roku od 24 sata",
|
||||||
"shippingTitle": "Dostava",
|
"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",
|
"deliveryTime": "Isporučeno u roku od 2-5 radnih dana",
|
||||||
"location": "Lokacija",
|
"location": "Lokacija",
|
||||||
"locationDesc": "Srbija",
|
"locationDesc": "Srbija",
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
"naturalIngredients": "Prirodni sastojci",
|
"naturalIngredients": "Prirodni sastojci",
|
||||||
"noAdditives": "Bez aditiva",
|
"noAdditives": "Bez aditiva",
|
||||||
"freeShipping": "Besplatna dostava",
|
"freeShipping": "Besplatna dostava",
|
||||||
"ordersOver": "Porudžbine preko 3.000 RSD"
|
"ordersOver": "Porudžbine preko 10.000 RSD"
|
||||||
},
|
},
|
||||||
"ProblemSection": {
|
"ProblemSection": {
|
||||||
"title": "Problem",
|
"title": "Problem",
|
||||||
@@ -324,7 +324,7 @@
|
|||||||
"qty": "Kol",
|
"qty": "Kol",
|
||||||
"adding": "Dodavanje...",
|
"adding": "Dodavanje...",
|
||||||
"transformHairSkin": "Transformiši kosu i kožu",
|
"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",
|
"guarantee": "30-dnevna garancija",
|
||||||
"secureCheckout": "Sigurno plaćanje",
|
"secureCheckout": "Sigurno plaćanje",
|
||||||
"easyReturns": "Lak povrat",
|
"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() {
|
export function useAnalytics() {
|
||||||
const op = useOpenPanel();
|
const op = useOpenPanel();
|
||||||
|
|
||||||
// Page views are tracked automatically by OpenPanelComponent
|
// Client-side tracking for user behavior
|
||||||
// but we can track specific events manually
|
|
||||||
|
|
||||||
const trackProductView = useCallback((product: {
|
const trackProductView = useCallback((product: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -16,13 +14,18 @@ export function useAnalytics() {
|
|||||||
currency: string;
|
currency: string;
|
||||||
category?: string;
|
category?: string;
|
||||||
}) => {
|
}) => {
|
||||||
op.track("product_viewed", {
|
try {
|
||||||
product_id: product.id,
|
op.track("product_viewed", {
|
||||||
product_name: product.name,
|
product_id: product.id,
|
||||||
price: product.price,
|
product_name: product.name,
|
||||||
currency: product.currency,
|
price: product.price,
|
||||||
category: product.category,
|
currency: product.currency,
|
||||||
});
|
category: product.category,
|
||||||
|
source: "client",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Client Analytics] Product view error:", e);
|
||||||
|
}
|
||||||
}, [op]);
|
}, [op]);
|
||||||
|
|
||||||
const trackAddToCart = useCallback((product: {
|
const trackAddToCart = useCallback((product: {
|
||||||
@@ -33,14 +36,19 @@ export function useAnalytics() {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
}) => {
|
}) => {
|
||||||
op.track("add_to_cart", {
|
try {
|
||||||
product_id: product.id,
|
op.track("add_to_cart", {
|
||||||
product_name: product.name,
|
product_id: product.id,
|
||||||
price: product.price,
|
product_name: product.name,
|
||||||
currency: product.currency,
|
price: product.price,
|
||||||
quantity: product.quantity,
|
currency: product.currency,
|
||||||
variant: product.variant,
|
quantity: product.quantity,
|
||||||
});
|
variant: product.variant,
|
||||||
|
source: "client",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Client Analytics] Add to cart error:", e);
|
||||||
|
}
|
||||||
}, [op]);
|
}, [op]);
|
||||||
|
|
||||||
const trackRemoveFromCart = useCallback((product: {
|
const trackRemoveFromCart = useCallback((product: {
|
||||||
@@ -48,11 +56,16 @@ export function useAnalytics() {
|
|||||||
name: string;
|
name: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
}) => {
|
}) => {
|
||||||
op.track("remove_from_cart", {
|
try {
|
||||||
product_id: product.id,
|
op.track("remove_from_cart", {
|
||||||
product_name: product.name,
|
product_id: product.id,
|
||||||
quantity: product.quantity,
|
product_name: product.name,
|
||||||
});
|
quantity: product.quantity,
|
||||||
|
source: "client",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Client Analytics] Remove from cart error:", e);
|
||||||
|
}
|
||||||
}, [op]);
|
}, [op]);
|
||||||
|
|
||||||
const trackCheckoutStarted = useCallback((cart: {
|
const trackCheckoutStarted = useCallback((cart: {
|
||||||
@@ -66,22 +79,37 @@ export function useAnalytics() {
|
|||||||
price: number;
|
price: number;
|
||||||
}>;
|
}>;
|
||||||
}) => {
|
}) => {
|
||||||
op.track("checkout_started", {
|
try {
|
||||||
cart_total: cart.total,
|
op.track("checkout_started", {
|
||||||
currency: cart.currency,
|
cart_total: cart.total,
|
||||||
item_count: cart.item_count,
|
currency: cart.currency,
|
||||||
items: cart.items,
|
item_count: cart.item_count,
|
||||||
});
|
items: cart.items,
|
||||||
|
source: "client",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Client Analytics] Checkout started error:", e);
|
||||||
|
}
|
||||||
}, [op]);
|
}, [op]);
|
||||||
|
|
||||||
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
|
const trackCheckoutStep = useCallback((step: string, data?: Record<string, unknown>) => {
|
||||||
op.track("checkout_step", {
|
try {
|
||||||
step,
|
op.track("checkout_step", {
|
||||||
...data,
|
step,
|
||||||
});
|
...data,
|
||||||
|
source: "client",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Client Analytics] Checkout step error:", e);
|
||||||
|
}
|
||||||
}, [op]);
|
}, [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_id: string;
|
||||||
order_number: string;
|
order_number: string;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -89,37 +117,86 @@ export function useAnalytics() {
|
|||||||
item_count: number;
|
item_count: number;
|
||||||
shipping_cost?: number;
|
shipping_cost?: number;
|
||||||
customer_email?: string;
|
customer_email?: string;
|
||||||
|
payment_method?: string;
|
||||||
}) => {
|
}) => {
|
||||||
op.track("order_completed", {
|
console.log("[Dual Analytics] Tracking order:", order.order_number, "Total:", order.total);
|
||||||
order_id: order.order_id,
|
|
||||||
order_number: order.order_number,
|
|
||||||
total: order.total,
|
|
||||||
currency: order.currency,
|
|
||||||
item_count: order.item_count,
|
|
||||||
shipping_cost: order.shipping_cost,
|
|
||||||
customer_email: order.customer_email,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also track revenue for analytics
|
// CLIENT-SIDE: Track immediately for user session data
|
||||||
op.track("purchase", {
|
try {
|
||||||
transaction_id: order.order_number,
|
op.track("order_completed", {
|
||||||
value: order.total,
|
order_id: order.order_id,
|
||||||
currency: order.currency,
|
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]);
|
}, [op]);
|
||||||
|
|
||||||
const trackSearch = useCallback((query: string, results_count: number) => {
|
const trackSearch = useCallback((query: string, results_count: number) => {
|
||||||
op.track("search", {
|
try {
|
||||||
query,
|
op.track("search", {
|
||||||
results_count,
|
query,
|
||||||
});
|
results_count,
|
||||||
|
source: "client",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Client Analytics] Search error:", e);
|
||||||
|
}
|
||||||
}, [op]);
|
}, [op]);
|
||||||
|
|
||||||
const trackExternalLink = useCallback((url: string, label?: string) => {
|
const trackExternalLink = useCallback((url: string, label?: string) => {
|
||||||
op.track("external_link_click", {
|
try {
|
||||||
url,
|
op.track("external_link_click", {
|
||||||
label,
|
url,
|
||||||
});
|
label,
|
||||||
|
source: "client",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Client Analytics] External link error:", e);
|
||||||
|
}
|
||||||
}, [op]);
|
}, [op]);
|
||||||
|
|
||||||
const identifyUser = useCallback((user: {
|
const identifyUser = useCallback((user: {
|
||||||
@@ -127,15 +204,17 @@ export function useAnalytics() {
|
|||||||
email?: string;
|
email?: string;
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
properties?: Record<string, unknown>;
|
|
||||||
}) => {
|
}) => {
|
||||||
op.identify({
|
try {
|
||||||
profileId: user.profileId,
|
op.identify({
|
||||||
firstName: user.firstName,
|
profileId: user.profileId,
|
||||||
lastName: user.lastName,
|
firstName: user.firstName,
|
||||||
email: user.email,
|
lastName: user.lastName,
|
||||||
properties: user.properties,
|
email: user.email,
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[Client Analytics] Identify error:", e);
|
||||||
|
}
|
||||||
}, [op]);
|
}, [op]);
|
||||||
|
|
||||||
return {
|
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