Merge branch 'dev'
Some checks failed
Build and Deploy / build (push) Has been cancelled

This commit is contained in:
Unchained
2026-03-30 06:35:45 +02:00
13 changed files with 812 additions and 109 deletions

0
1 Normal file
View File

388
docs/ANALYTICS_GUIDE.md Normal file
View 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

View File

@@ -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>

View 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 }
);
}
}

View File

@@ -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",

View File

@@ -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 = () => {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View 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) };
}
}

View File

@@ -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 {

View 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,
};
}