diff --git a/apps/web/src/features/cart/CartPage.tsx b/apps/web/src/features/cart/CartPage.tsx index fc56a67..674bb67 100644 --- a/apps/web/src/features/cart/CartPage.tsx +++ b/apps/web/src/features/cart/CartPage.tsx @@ -5,6 +5,7 @@ import { removeCartItem, updateCartItemQuantity, } from "../../services/cartApi"; +import { recordUserBehaviorEvent } from "../../services/eventLogApi"; import { clearStoredCartId, clearStoredPendingOrder, @@ -123,6 +124,20 @@ export function CartPage() { loadCart(); }, [loadCart]); + useEffect(() => { + void recordUserBehaviorEvent({ + event_name: "cart_viewed", + user_id: userId, + session_id: null, + entity_type: storedCartId ? "cart" : null, + entity_id: storedCartId, + properties: { + page_path: window.location.pathname, + cart_id: storedCartId, + }, + }); + }, [storedCartId, userId]); + useEffect(() => { const nextDrafts = cartItems.reduce>((drafts, item) => { drafts[item.cart_item_id] = String(item.quantity); @@ -137,6 +152,24 @@ export function CartPage() { return; } + const targetItem = cartItems.find((item) => item.cart_item_id === cartItemId); + + void recordUserBehaviorEvent({ + event_name: "cart_item_remove_clicked", + user_id: userId, + session_id: null, + entity_type: "cart", + entity_id: storedCartId, + properties: { + page_path: window.location.pathname, + cart_id: storedCartId, + cart_item_id: cartItemId, + product_id: targetItem?.product_id ?? null, + product_name: targetItem?.product_name ?? null, + quantity: targetItem?.quantity ?? null, + }, + }); + try { setRemovingItemId(cartItemId); setActionMessage(null); @@ -161,6 +194,23 @@ export function CartPage() { const normalizedQuantity = Math.max(0, Math.min(99, nextQuantity)); + void recordUserBehaviorEvent({ + event_name: "cart_quantity_change_clicked", + user_id: userId, + session_id: null, + entity_type: "cart", + entity_id: storedCartId, + properties: { + page_path: window.location.pathname, + cart_id: storedCartId, + cart_item_id: item.cart_item_id, + product_id: item.product_id, + product_name: item.product_name ?? null, + previous_quantity: item.quantity, + next_quantity: normalizedQuantity, + }, + }); + try { setUpdatingItemId(item.cart_item_id); setActionMessage(null); diff --git a/apps/web/src/features/checkout/CheckoutPage.tsx b/apps/web/src/features/checkout/CheckoutPage.tsx index c08a509..6b4f134 100644 --- a/apps/web/src/features/checkout/CheckoutPage.tsx +++ b/apps/web/src/features/checkout/CheckoutPage.tsx @@ -6,6 +6,7 @@ import { enterCheckout, simulatePayment, } from "../../services/checkoutApi"; +import { recordUserBehaviorEvent } from "../../services/eventLogApi"; import { clearStoredCartId, clearStoredPendingOrder, @@ -169,6 +170,20 @@ export function CheckoutPage() { loadCheckout(); }, [checkoutCartId, userId]); + useEffect(() => { + void recordUserBehaviorEvent({ + event_name: "checkout_started", + user_id: userId, + session_id: null, + entity_type: checkoutCartId ? "cart" : null, + entity_id: checkoutCartId, + properties: { + page_path: window.location.pathname, + cart_id: checkoutCartId, + }, + }); + }, [checkoutCartId, userId]); + const handleCouponCodeChange = (event: ChangeEvent) => { setCouponCode(event.target.value); }; @@ -225,6 +240,24 @@ export function CheckoutPage() { return; } + void recordUserBehaviorEvent({ + event_name: "order_create_clicked", + user_id: userId, + session_id: null, + entity_type: "cart", + entity_id: checkoutCartId, + properties: { + page_path: window.location.pathname, + cart_id: checkoutCartId, + coupon_name: + appliedCoupon?.coupon?.coupon_name ?? + appliedCoupon?.coupon_name ?? + appliedCoupon?.coupon_code ?? + null, + final_amount: finalAmount, + }, + }); + try { setIsCreatingOrder(true); setErrorMessage(null); @@ -273,6 +306,24 @@ export function CheckoutPage() { return; } + void recordUserBehaviorEvent({ + event_name: + simulateResult === "success" + ? "payment_success_clicked" + : "payment_fail_clicked", + user_id: userId, + session_id: null, + entity_type: "order", + entity_id: createdOrder.order_id, + properties: { + page_path: window.location.pathname, + cart_id: checkoutCartId, + order_id: createdOrder.order_id, + payment_method: PAYMENT_METHOD, + simulate_result: simulateResult, + }, + }); + try { setIsPaying(true); setErrorMessage(null); diff --git a/apps/web/src/features/coupons/CouponWalletPage.tsx b/apps/web/src/features/coupons/CouponWalletPage.tsx index 93ef1a7..3a3a85b 100644 --- a/apps/web/src/features/coupons/CouponWalletPage.tsx +++ b/apps/web/src/features/coupons/CouponWalletPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; import { getUserCoupons } from "../../services/couponApi"; +import { recordUserBehaviorEvent } from "../../services/eventLogApi"; import { getStoredUser } from "../../stores/userStore"; import type { UsedCoupon, UserCoupon, UserCouponWalletResponse } from "../../types/coupon"; @@ -156,6 +157,19 @@ export function CouponWalletPage() { loadCoupons(); }, [userId]); + useEffect(() => { + void recordUserBehaviorEvent({ + event_name: "coupon_wallet_viewed", + user_id: userId, + session_id: null, + entity_type: null, + entity_id: null, + properties: { + page_path: window.location.pathname, + }, + }); + }, [userId]); + if (!userId) { return (
diff --git a/apps/web/src/features/orders/OrderHistoryPage.tsx b/apps/web/src/features/orders/OrderHistoryPage.tsx index e95f1f4..17b3658 100644 --- a/apps/web/src/features/orders/OrderHistoryPage.tsx +++ b/apps/web/src/features/orders/OrderHistoryPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; +import { recordUserBehaviorEvent } from "../../services/eventLogApi"; import { getOrderHistory } from "../../services/orderApi"; import { getStoredUser } from "../../stores/userStore"; import type { OrderHistoryItem, OrderItem } from "../../types/order"; @@ -296,6 +297,19 @@ export function OrderHistoryPage() { loadOrders(); }, [userId]); + useEffect(() => { + void recordUserBehaviorEvent({ + event_name: "order_history_viewed", + user_id: userId, + session_id: null, + entity_type: null, + entity_id: null, + properties: { + page_path: window.location.pathname, + }, + }); + }, [userId]); + if (!userId) { return (
diff --git a/apps/web/src/features/products/ProductDetailPage.tsx b/apps/web/src/features/products/ProductDetailPage.tsx index 81a6d00..5d776d3 100644 --- a/apps/web/src/features/products/ProductDetailPage.tsx +++ b/apps/web/src/features/products/ProductDetailPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { Link, useLocation, useParams } from "react-router-dom"; import { addCartItem, createCart } from "../../services/cartApi"; import { getProductDetail } from "../../services/catalogApi"; +import { recordUserBehaviorEvent } from "../../services/eventLogApi"; import { getProductReviews } from "../../services/reviewApi"; import { clearStoredCartId, @@ -138,6 +139,26 @@ export function ProductDetailPage() { loadProductDetail(); }, [productId]); + useEffect(() => { + if (!product) { + return; + } + + void recordUserBehaviorEvent({ + event_name: "product_detail_viewed", + user_id: user?.user_id ?? null, + session_id: null, + entity_type: "product", + entity_id: product.product_id, + properties: { + page_path: window.location.pathname, + product_id: product.product_id, + product_name: product.product_name, + source_page: "product_detail", + }, + }); + }, [product?.product_id, user?.user_id]); + useEffect(() => { async function loadProductReviews() { if (!productId) { @@ -210,6 +231,21 @@ export function ProductDetailPage() { return; } + void recordUserBehaviorEvent({ + event_name: "product_add_to_cart_clicked", + user_id: user?.user_id ?? null, + session_id: null, + entity_type: "product", + entity_id: product.product_id, + properties: { + page_path: window.location.pathname, + product_id: product.product_id, + product_name: product.product_name, + source_page: "product_detail", + quantity, + }, + }); + if (!user) { setCartMessage(null); setCartErrorMessage("로그인 후 장바구니에 상품을 담을 수 있습니다."); diff --git a/apps/web/src/features/products/ProductListPage.tsx b/apps/web/src/features/products/ProductListPage.tsx index 9bc4644..a5fa96b 100644 --- a/apps/web/src/features/products/ProductListPage.tsx +++ b/apps/web/src/features/products/ProductListPage.tsx @@ -10,6 +10,7 @@ import { import { Link } from "react-router-dom"; import { addCartItem, createCart } from "../../services/cartApi"; import { getCategories, getProducts } from "../../services/catalogApi"; +import { recordUserBehaviorEvent } from "../../services/eventLogApi"; import { clearStoredCartId, clearStoredPendingOrder, @@ -58,6 +59,21 @@ export function ProductListPage() { const productListTopRef = useRef(null); + useEffect(() => { + const user = getStoredUser(); + + void recordUserBehaviorEvent({ + event_name: "product_list_viewed", + user_id: user?.user_id ?? null, + session_id: null, + entity_type: null, + entity_id: null, + properties: { + page_path: window.location.pathname, + }, + }); + }, []); + const scrollToProductListTop = useCallback(() => { requestAnimationFrame(() => { productListTopRef.current?.scrollIntoView({ @@ -152,6 +168,21 @@ export function ProductListPage() { const user = getStoredUser(); + void recordUserBehaviorEvent({ + event_name: "product_add_to_cart_clicked", + user_id: user?.user_id ?? null, + session_id: null, + entity_type: "product", + entity_id: product.product_id, + properties: { + page_path: window.location.pathname, + product_id: product.product_id, + product_name: product.product_name, + source_page: "product_list", + quantity: QUICK_ADD_QUANTITY, + }, + }); + if (!user) { setCartFeedbackMessage(null); setCartErrorMessage("로그인 후 장바구니에 상품을 담을 수 있습니다."); diff --git a/apps/web/src/features/reviews/ReviewCreatePage.tsx b/apps/web/src/features/reviews/ReviewCreatePage.tsx index 67bbc37..734ed1d 100644 --- a/apps/web/src/features/reviews/ReviewCreatePage.tsx +++ b/apps/web/src/features/reviews/ReviewCreatePage.tsx @@ -1,6 +1,7 @@ -import { FormEvent, useState } from "react"; +import { FormEvent, useEffect, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { ApiError } from "../../services/apiClient"; +import { recordUserBehaviorEvent } from "../../services/eventLogApi"; import { createReview } from "../../services/reviewApi"; import { getStoredUser } from "../../stores/userStore"; @@ -42,9 +43,43 @@ export function ReviewCreatePage() { const isInvalidReviewTarget = !validProductId || !validOrderItemId; + useEffect(() => { + void recordUserBehaviorEvent({ + event_name: "review_create_page_viewed", + user_id: userId, + session_id: null, + entity_type: validProductId ? "product" : null, + entity_id: validProductId, + properties: { + page_path: window.location.pathname, + product_id: validProductId, + order_item_id: validOrderItemId, + product_name: productName, + }, + }); + }, [userId, validProductId, validOrderItemId, productName]); + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); + void recordUserBehaviorEvent({ + event_name: "review_submit_clicked", + user_id: userId, + session_id: null, + entity_type: validProductId ? "product" : null, + entity_id: validProductId, + properties: { + page_path: window.location.pathname, + product_id: validProductId, + order_item_id: validOrderItemId, + product_name: productName, + rating, + recommendation, + has_review_title: Boolean(reviewTitle.trim()), + has_review_content: Boolean(reviewContent.trim()), + }, + }); + setErrorMessage(null); setSuccessMessage(null); diff --git a/apps/web/src/services/eventLogApi.ts b/apps/web/src/services/eventLogApi.ts new file mode 100644 index 0000000..8da2a3a --- /dev/null +++ b/apps/web/src/services/eventLogApi.ts @@ -0,0 +1,35 @@ +import type { + EventLogCreateRequest, + EventLogCreateResponse, +} from "../types/eventLog"; + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000"; + +export async function recordUserBehaviorEvent( + payload: Omit, +): Promise { + try { + const response = await fetch(`${API_BASE_URL}/events`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...payload, + event_type: "user_behavior", + source: "frontend", + properties: payload.properties ?? {}, + }), + }); + + if (!response.ok) { + console.error("Failed to record user behavior event", response.status); + return null; + } + + return (await response.json()) as EventLogCreateResponse; + } catch (error) { + console.error("Failed to record user behavior event", error); + return null; + } +} \ No newline at end of file diff --git a/apps/web/src/types/eventLog.ts b/apps/web/src/types/eventLog.ts new file mode 100644 index 0000000..d9cb138 --- /dev/null +++ b/apps/web/src/types/eventLog.ts @@ -0,0 +1,29 @@ +export type EventLogType = "user_behavior" | "domain_event" | "system_event"; + +export type EventLogSource = "frontend" | "backend" | "script"; + +export type EventLogCreateRequest = { + event_name: string; + event_type: EventLogType; + source: EventLogSource; + user_id?: string | null; + session_id?: string | null; + entity_type?: string | null; + entity_id?: string | null; + properties?: Record; +}; + +export type EventLogCreateResponse = { + event_id: string; + event_name: string; + event_type: EventLogType; + user_id: string | null; + session_id: string | null; + entity_type: string | null; + entity_id: string | null; + occurred_at: string; + source: EventLogSource; + properties: Record; + created_at: string; + message: string; +}; \ No newline at end of file