diff --git a/1 b/1 new file mode 100644 index 0000000..e69de29 diff --git a/docs/ANALYTICS_GUIDE.md b/docs/ANALYTICS_GUIDE.md new file mode 100644 index 0000000..6331456 --- /dev/null +++ b/docs/ANALYTICS_GUIDE.md @@ -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 diff --git a/src/app/[locale]/checkout/page.tsx b/src/app/[locale]/checkout/page.tsx index cfcac3d..60b4fd8 100644 --- a/src/app/[locale]/checkout/page.tsx +++ b/src/app/[locale]/checkout/page.tsx @@ -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(""); 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(() => { @@ -148,10 +158,12 @@ export default function CheckoutPage() { console.log("Available shipping methods:", availableMethods); setShippingMethods(availableMethods); - + // 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" /> {method.name} diff --git a/src/app/api/analytics/track-order/route.ts b/src/app/api/analytics/track-order/route.ts new file mode 100644 index 0000000..4ebf361 --- /dev/null +++ b/src/app/api/analytics/track-order/route.ts @@ -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 } + ); + } +} diff --git a/src/components/home/TickerBar.tsx b/src/components/home/TickerBar.tsx index 3b145d7..c86c276 100644 --- a/src/components/home/TickerBar.tsx +++ b/src/components/home/TickerBar.tsx @@ -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", diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 6a3aa2d..c986cdf 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -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 = () => { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 17f6411..b4a5b16 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -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", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index f8dad44..9554bfb 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 44e79f2..926d26c 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -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", diff --git a/src/i18n/messages/sr.json b/src/i18n/messages/sr.json index d07dad6..50af126 100644 --- a/src/i18n/messages/sr.json +++ b/src/i18n/messages/sr.json @@ -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", diff --git a/src/lib/analytics-server.ts b/src/lib/analytics-server.ts new file mode 100644 index 0000000..f451d9e --- /dev/null +++ b/src/lib/analytics-server.ts @@ -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; +} + +/** + * 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) { + try { + await op.identify({ + profileId, + ...properties, + }); + return { success: true }; + } catch (error) { + console.error("[Server Analytics] Identify failed:", error); + return { success: false, error: String(error) }; + } +} diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index b4506bd..3d6d1ea 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -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) => { - 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; }) => { - 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 { diff --git a/src/lib/hooks/useShippingMethodSelector.ts b/src/lib/hooks/useShippingMethodSelector.ts new file mode 100644 index 0000000..b907bfc --- /dev/null +++ b/src/lib/hooks/useShippingMethodSelector.ts @@ -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; +} + +interface UseShippingMethodSelectorResult { + selectShippingMethod: (methodId: string) => Promise; + selectShippingMethodWithApi: (methodId: string) => Promise; +} + +/** + * 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, + }; +}