first commit
This commit is contained in:
212
lib/api.ts
Normal file
212
lib/api.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios"
|
||||
|
||||
/**
|
||||
* Token management utilities
|
||||
*/
|
||||
const getTokenFromCookie = (name: string): string | null => {
|
||||
if (typeof document === "undefined") return null
|
||||
const value = `; ${document.cookie}`
|
||||
const parts = value.split(`; ${name}=`)
|
||||
if (parts.length === 2) return parts.pop()?.split(";").shift() || null
|
||||
return null
|
||||
}
|
||||
|
||||
const setTokenInCookie = (name: string, token: string): void => {
|
||||
if (typeof document === "undefined") return
|
||||
document.cookie = `${name}=${token}; path=/; secure; SameSite=Strict; max-age=2592000`
|
||||
}
|
||||
|
||||
const removeTokenFromCookie = (name: string): void => {
|
||||
if (typeof document === "undefined") return
|
||||
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;`
|
||||
}
|
||||
|
||||
const getToken = (): string | null => {
|
||||
// Check cookies first (more secure)
|
||||
const authToken = getTokenFromCookie("authToken")
|
||||
if (authToken) return authToken
|
||||
|
||||
const guestToken = getTokenFromCookie("guestToken")
|
||||
if (guestToken) return guestToken
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized API client with interceptors
|
||||
*/
|
||||
class APIClient {
|
||||
private client: AxiosInstance
|
||||
private baseUrl: string
|
||||
private isRefreshing = false
|
||||
private failedQueue: Array<{
|
||||
resolve: (value?: unknown) => void
|
||||
reject: (reason?: unknown) => void
|
||||
}> = []
|
||||
|
||||
constructor() {
|
||||
this.baseUrl = process.env.NEXT_PUBLIC_API_URL || "https://api.example.com"
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: `${this.baseUrl}/api/v1`,
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Api-Token": process.env.NEXT_PUBLIC_API_TOKEN || "123",
|
||||
},
|
||||
})
|
||||
|
||||
this.setupInterceptors()
|
||||
}
|
||||
|
||||
private setupInterceptors(): void {
|
||||
// Request interceptor
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// Add language parameter if i18n is available
|
||||
if (typeof window !== "undefined" && (window as any).i18n) {
|
||||
const lang = (window as any).i18n.language || "tm"
|
||||
const url = config.url || ""
|
||||
config.url = `${url}${url.includes("?") ? "&" : "?"}lang=${lang}`
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// Response interceptor
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config
|
||||
|
||||
// Handle 401 errors
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
if (this.isRefreshing) {
|
||||
// Queue requests while refreshing
|
||||
return new Promise((resolve, reject) => {
|
||||
this.failedQueue.push({ resolve, reject })
|
||||
})
|
||||
.then(() => this.client(originalRequest))
|
||||
.catch((err) => Promise.reject(err))
|
||||
}
|
||||
|
||||
originalRequest._retry = true
|
||||
this.isRefreshing = true
|
||||
|
||||
try {
|
||||
// Attempt to get guest token
|
||||
const guestTokenResponse = await axios.post(
|
||||
`${this.baseUrl}/api/v1/auth/guest-token`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Api-Token": process.env.NEXT_PUBLIC_API_TOKEN || "123",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const newToken = guestTokenResponse.data?.token || guestTokenResponse.data?.data
|
||||
|
||||
if (newToken) {
|
||||
setTokenInCookie("guestToken", newToken)
|
||||
this.processQueue(null)
|
||||
return this.client(originalRequest)
|
||||
}
|
||||
} catch (refreshError) {
|
||||
this.processQueue(refreshError)
|
||||
this.clearAuthToken()
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/login"
|
||||
}
|
||||
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
this.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle HTML error responses
|
||||
if (
|
||||
error.response?.data &&
|
||||
typeof error.response.data === "string" &&
|
||||
error.response.data.includes("<!DOCTYPE html>")
|
||||
) {
|
||||
return Promise.reject({
|
||||
...error,
|
||||
response: {
|
||||
...error.response,
|
||||
data: { message: "Server returned HTML instead of JSON" },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private processQueue(error: any): void {
|
||||
this.failedQueue.forEach((promise) => {
|
||||
if (error) {
|
||||
promise.reject(error)
|
||||
} else {
|
||||
promise.resolve()
|
||||
}
|
||||
})
|
||||
this.failedQueue = []
|
||||
}
|
||||
|
||||
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.client.get<T>(url, config)
|
||||
}
|
||||
|
||||
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.client.post<T>(url, data, config)
|
||||
}
|
||||
|
||||
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.client.put<T>(url, data, config)
|
||||
}
|
||||
|
||||
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.client.patch<T>(url, data, config)
|
||||
}
|
||||
|
||||
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
|
||||
return this.client.delete<T>(url, config)
|
||||
}
|
||||
|
||||
setAuthToken(token: string): void {
|
||||
removeTokenFromCookie("guestToken")
|
||||
setTokenInCookie("authToken", token)
|
||||
this.client.defaults.headers.common["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
setGuestToken(token: string): void {
|
||||
setTokenInCookie("guestToken", token)
|
||||
this.client.defaults.headers.common["Authorization"] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
clearAuthToken(): void {
|
||||
removeTokenFromCookie("authToken")
|
||||
removeTokenFromCookie("guestToken")
|
||||
delete this.client.defaults.headers.common["Authorization"]
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const apiClient = new APIClient()
|
||||
|
||||
// Export helper functions
|
||||
export const setAuthToken = (token: string) => apiClient.setAuthToken(token)
|
||||
export const setGuestToken = (token: string) => apiClient.setGuestToken(token)
|
||||
export const clearAuthToken = () => apiClient.clearAuthToken()
|
||||
58
lib/config/api-endpoints.ts
Normal file
58
lib/config/api-endpoints.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* API Endpoints Configuration
|
||||
* Centralized mapping of all API endpoints
|
||||
*/
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
// Products
|
||||
products: "/api/v1/products",
|
||||
productDetail: (id: string | number) => `/api/v1/products/${id}`,
|
||||
productsByCategory: (categoryId: string | number) => `/api/v1/categories/${categoryId}/products`,
|
||||
productsByBrand: (brandId: string | number) => `/api/v1/brands/${brandId}/products`,
|
||||
|
||||
// Categories
|
||||
categories: "/api/v1/categories",
|
||||
categoryDetail: (id: string | number) => `/api/v1/categories/${id}`,
|
||||
|
||||
// Search & Filters
|
||||
search: "/api/v1/search",
|
||||
filters: "/api/v1/filters",
|
||||
|
||||
// Cart
|
||||
cart: "/api/v1/carts",
|
||||
cartItems: "/api/v1/carts",
|
||||
cartItem: (itemId: string | number) => `/api/v1/carts/${itemId}`,
|
||||
|
||||
// Favorites
|
||||
favorites: "/api/v1/favorites",
|
||||
favoriteDetail: (productId: string | number) => `/api/v1/favorites/${productId}`,
|
||||
|
||||
// Orders
|
||||
orders: "/api/v1/orders",
|
||||
orderDetail: (id: string | number) => `/api/v1/orders/${id}`,
|
||||
cancelOrder: (id: string | number) => `/api/v1/orders/${id}/cancel`,
|
||||
|
||||
// Regions & Addresses
|
||||
regions: "/api/v1/regions",
|
||||
addresses: "/api/v1/addresses",
|
||||
|
||||
// Payment & Shipping
|
||||
paymentTypes: "/api/v1/order-payments",
|
||||
shippingMethods: "/api/v1/shipping-methods",
|
||||
|
||||
// Profile
|
||||
profile: "/api/v1/profile",
|
||||
profileMe: "/api/v1/me",
|
||||
|
||||
// Auth
|
||||
guestToken: "/api/v1/auth/guest-token",
|
||||
verifyCode: "/api/v1/auth/verify-code",
|
||||
|
||||
// Media
|
||||
banners: "/api/v1/media/banners",
|
||||
|
||||
// Forms
|
||||
newsletter: "/api/v1/forms/newsletter-subscription",
|
||||
contactUs: "/api/v1/forms/contact-us",
|
||||
openStore: "/api/v1/forms/open-store",
|
||||
} as const
|
||||
18
lib/hooks/index.ts
Normal file
18
lib/hooks/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export * from "./useProducts"
|
||||
export * from "./useCategories"
|
||||
export * from "./useCart"
|
||||
export * from "./useFavorites"
|
||||
export * from "./useOrders"
|
||||
export * from "./useSearch"
|
||||
export * from "./useUserProfile"
|
||||
export * from "./useOpenStore"
|
||||
export * from "./useRegions"
|
||||
export * from "./useAddresses"
|
||||
export * from "./usePaymentTypes"
|
||||
export * from "./useCategories"
|
||||
|
||||
export * from "./useMedia"
|
||||
export * from "./useCollections"
|
||||
|
||||
// Export types
|
||||
export type { Product, Category, Cart, CartItem, Order, Favorite, Banner } from "@/lib/types/api"
|
||||
14
lib/hooks/useAddresses.ts
Normal file
14
lib/hooks/useAddresses.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import type { Address } from "@/lib/types/api"
|
||||
|
||||
export function useAddresses() {
|
||||
return useQuery({
|
||||
queryKey: ["addresses"],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<Address[]>("/api/v1/addresses")
|
||||
return response.data
|
||||
},
|
||||
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||
})
|
||||
}
|
||||
192
lib/hooks/useAuth.ts
Normal file
192
lib/hooks/useAuth.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { apiClient, setAuthToken, clearAuthToken, setGuestToken } from "@/lib/api"
|
||||
import { queryClient } from "@/lib/queryClient"
|
||||
|
||||
interface LoginCredentials {
|
||||
phone_number: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
interface RegisterData {
|
||||
phone_number: string
|
||||
name?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
interface VerifyTokenData {
|
||||
phone_number: string
|
||||
code: string
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
token?: string
|
||||
data?: string
|
||||
user?: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Guest Token alma (RTK mantığı)
|
||||
*/
|
||||
export function useGetGuestToken() {
|
||||
return useMutation({
|
||||
mutationFn: async (): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>("/auth/guest-token", {}, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const token = data?.token || data?.data
|
||||
if (token) {
|
||||
setGuestToken(token)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error fetching guest token:", error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Login mutation (RTK mantığı)
|
||||
*/
|
||||
export function useLogin() {
|
||||
return useMutation({
|
||||
mutationFn: async (credentials: LoginCredentials): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>("/auth/login", credentials)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const token = data?.token || data?.data
|
||||
if (token) {
|
||||
setAuthToken(token)
|
||||
apiClient.setAuthToken(token)
|
||||
|
||||
// Tüm cache'i temizle ve yeniden fetch et
|
||||
queryClient.invalidateQueries()
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Login error:", error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register mutation (RTK mantığı)
|
||||
*/
|
||||
export function useRegister() {
|
||||
return useMutation({
|
||||
mutationFn: async (userData: RegisterData): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>("/auth/register", userData)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const token = data?.token || data?.data
|
||||
if (token) {
|
||||
setAuthToken(token)
|
||||
apiClient.setAuthToken(token)
|
||||
|
||||
// Tüm cache'i temizle
|
||||
queryClient.invalidateQueries()
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Register error:", error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Token doğrulama (RTK mantığı)
|
||||
*/
|
||||
export function useVerifyToken() {
|
||||
return useMutation({
|
||||
mutationFn: async (verifyData: VerifyTokenData): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>(
|
||||
"/auth/verify",
|
||||
verifyData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const token = data?.data || data?.token
|
||||
if (token) {
|
||||
setAuthToken(token)
|
||||
apiClient.setAuthToken(token)
|
||||
|
||||
// Tüm cache'i temizle
|
||||
queryClient.invalidateQueries()
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Error verifying token:", error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout işlemi
|
||||
*/
|
||||
export function useLogout() {
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
// Backend'e logout isteği gönder (eğer endpoint varsa)
|
||||
try {
|
||||
await apiClient.post("/auth/logout")
|
||||
} catch (error) {
|
||||
// Logout endpoint yoksa da devam et
|
||||
console.warn("Logout endpoint not available")
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
clearAuthToken()
|
||||
apiClient.clearAuthToken()
|
||||
|
||||
// Tüm cache'i temizle
|
||||
queryClient.clear()
|
||||
|
||||
// Login sayfasına yönlendir
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/login"
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Kullanıcı bilgilerini getir
|
||||
*/
|
||||
export function useUser(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ["user", "me"],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get("/auth/me")
|
||||
return response.data
|
||||
},
|
||||
enabled: options?.enabled !== false,
|
||||
staleTime: 1000 * 60 * 5, // 5 dakika
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication durumunu kontrol et
|
||||
*/
|
||||
export function useAuthStatus() {
|
||||
const { data: user, isLoading, error } = useUser({ enabled: true })
|
||||
|
||||
return {
|
||||
isAuthenticated: !!user && !error,
|
||||
isLoading,
|
||||
user,
|
||||
}
|
||||
}
|
||||
92
lib/hooks/useCart.ts
Normal file
92
lib/hooks/useCart.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import type { Cart, CartItem } from "@/lib/types/api"
|
||||
|
||||
export function useCart() {
|
||||
return useQuery({
|
||||
queryKey: ["cart"],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<Cart>("/api/v1/carts")
|
||||
return response.data
|
||||
},
|
||||
staleTime: 0, // Always fetch fresh
|
||||
retry: 1,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddToCart() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ productId, quantity = 1 }: { productId: number; quantity?: number }) => {
|
||||
const response = await apiClient.post<Cart>("/api/v1/carts", {
|
||||
product_id: productId,
|
||||
quantity,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["cart"] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("[v0] Add to cart error:", error.response?.data?.message || error.message)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveFromCart() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (itemId: number) => {
|
||||
await apiClient.delete(`/api/v1/carts/${itemId}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["cart"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateCartItemQuantity() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ itemId, quantity }: { itemId: number; quantity: number }) => {
|
||||
const response = await apiClient.patch<CartItem>(`/api/v1/carts/${itemId}`, {
|
||||
quantity,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["cart"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateOrder() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (payload: {
|
||||
customer_name?: string
|
||||
customer_phone?: string
|
||||
customer_address: string
|
||||
shipping_method: string
|
||||
payment_type_id: number
|
||||
delivery_time?: string
|
||||
delivery_at?: string
|
||||
region: string
|
||||
note?: string
|
||||
}) => {
|
||||
const response = await apiClient.post("/api/v1/orders", payload)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["cart"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["orders"] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("[v0] Create order error:", error.response?.data?.message || error.message)
|
||||
},
|
||||
})
|
||||
}
|
||||
158
lib/hooks/useCategories.ts
Normal file
158
lib/hooks/useCategories.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import type { Category, Product, PaginatedResponse } from "@/lib/types/api"
|
||||
|
||||
// Get all categories as tree
|
||||
export function useCategories(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ["categories"],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<PaginatedResponse<Category>>("/categories", {
|
||||
params: { type: "tree" },
|
||||
})
|
||||
return response.data.data || response.data
|
||||
},
|
||||
enabled: options?.enabled !== false,
|
||||
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||
})
|
||||
}
|
||||
|
||||
// Get single category by ID
|
||||
export function useCategory(id: number | string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ["category", id],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<Category>(`/categories/${id}`)
|
||||
return response.data
|
||||
},
|
||||
enabled: options?.enabled !== false && !!id,
|
||||
staleTime: 1000 * 60 * 15,
|
||||
})
|
||||
}
|
||||
|
||||
// Get products for a single category with pagination
|
||||
export function useCategoryProducts(
|
||||
categoryId: number | string,
|
||||
options?: {
|
||||
enabled?: boolean
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["category", categoryId, "products", options?.page, options?.limit],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<PaginatedResponse<Product>>(
|
||||
`/categories/${categoryId}/products`,
|
||||
{
|
||||
params: {
|
||||
page: options?.page || 1,
|
||||
limit: options?.limit
|
||||
},
|
||||
}
|
||||
)
|
||||
return {
|
||||
data: response.data.data || [],
|
||||
pagination: response.data.pagination || {}
|
||||
}
|
||||
},
|
||||
enabled: options?.enabled !== false && !!categoryId,
|
||||
})
|
||||
}
|
||||
|
||||
// Get ALL products from category and its children - NO pagination (for initial load)
|
||||
export function useAllCategoryProducts(
|
||||
category: Category | undefined,
|
||||
options?: { enabled?: boolean }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["category", category?.id, "all-products"],
|
||||
queryFn: async () => {
|
||||
if (!category) return []
|
||||
|
||||
const fetchProducts = async (categoryId: number) => {
|
||||
const response = await apiClient.get<PaginatedResponse<Product>>(
|
||||
`/categories/${categoryId}/products`
|
||||
)
|
||||
return response.data.data || []
|
||||
}
|
||||
|
||||
let allProducts = await fetchProducts(category.id)
|
||||
|
||||
if (category.children && category.children.length > 0) {
|
||||
for (const child of category.children) {
|
||||
const childProducts = await fetchProducts(child.id)
|
||||
allProducts = [...allProducts, ...childProducts]
|
||||
}
|
||||
}
|
||||
|
||||
return allProducts
|
||||
},
|
||||
enabled: options?.enabled !== false && !!category,
|
||||
})
|
||||
}
|
||||
|
||||
// Get products from category and children WITH pagination (mimics RTK getAllCategoryProductsPaginated)
|
||||
export function useAllCategoryProductsPaginated(
|
||||
category: Category | undefined,
|
||||
options?: {
|
||||
enabled?: boolean
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
) {
|
||||
const page = options?.page || 1
|
||||
const limit = options?.limit || 6
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["category", category?.id, "paginated-products", page, limit],
|
||||
queryFn: async () => {
|
||||
if (!category) {
|
||||
return {
|
||||
data: [],
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
hasMorePages: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const categoryIds = [category.id]
|
||||
if (category.children && category.children.length > 0) {
|
||||
category.children.forEach((child) => categoryIds.push(child.id))
|
||||
}
|
||||
|
||||
const perCategoryLimit = Math.ceil(limit / categoryIds.length)
|
||||
const hasMoreByCategory: Record<number, boolean> = {}
|
||||
let allPageProducts: Product[] = []
|
||||
|
||||
for (const categoryId of categoryIds) {
|
||||
const response = await apiClient.get<PaginatedResponse<Product>>(
|
||||
`/categories/${categoryId}/products`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
limit: perCategoryLimit
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (response.data.data) {
|
||||
allPageProducts = [...allPageProducts, ...response.data.data]
|
||||
hasMoreByCategory[categoryId] = !!response.data.pagination?.next_page_url
|
||||
}
|
||||
}
|
||||
|
||||
const hasMorePages = Object.values(hasMoreByCategory).some((hasMore) => hasMore)
|
||||
|
||||
return {
|
||||
data: allPageProducts,
|
||||
pagination: {
|
||||
currentPage: page,
|
||||
hasMorePages
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled: options?.enabled !== false && !!category,
|
||||
})
|
||||
}
|
||||
108
lib/hooks/useCollections.ts
Normal file
108
lib/hooks/useCollections.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import type { Collection, Product, PaginatedResponse } from "@/lib/types/api"
|
||||
|
||||
// Get all collections
|
||||
export function useCollections(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ["collections"],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<PaginatedResponse<Collection>>("/collections")
|
||||
return response.data.data || response.data
|
||||
},
|
||||
enabled: options?.enabled !== false,
|
||||
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||
})
|
||||
}
|
||||
|
||||
// Get single collection by ID
|
||||
export function useCollection(id: number | string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ["collection", id],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<Collection>(`/collections/${id}`)
|
||||
return response.data
|
||||
},
|
||||
enabled: options?.enabled !== false && !!id,
|
||||
staleTime: 1000 * 60 * 15,
|
||||
})
|
||||
}
|
||||
|
||||
// Get collection products (non-paginated)
|
||||
export function useCollectionProducts(
|
||||
collectionId: number | string,
|
||||
options?: { enabled?: boolean }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["collection", collectionId, "products"],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<PaginatedResponse<Product>>(
|
||||
`/collections/${collectionId}/products`
|
||||
)
|
||||
const data = response.data.data || []
|
||||
return {
|
||||
data,
|
||||
isEmpty: data.length === 0,
|
||||
}
|
||||
},
|
||||
enabled: options?.enabled !== false && !!collectionId,
|
||||
})
|
||||
}
|
||||
|
||||
// Check if collection has products (limit=1 for efficiency)
|
||||
export function useCollectionHasProducts(
|
||||
collectionId: number | string,
|
||||
options?: { enabled?: boolean }
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["collection", collectionId, "has-products"],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<PaginatedResponse<Product>>(
|
||||
`/collections/${collectionId}/products`,
|
||||
{
|
||||
params: { limit: 1 },
|
||||
}
|
||||
)
|
||||
return {
|
||||
hasProducts: response.data.data && response.data.data.length > 0,
|
||||
}
|
||||
},
|
||||
enabled: options?.enabled !== false && !!collectionId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
})
|
||||
}
|
||||
|
||||
// Get collection products with pagination
|
||||
export function useCollectionProductsPaginated(
|
||||
collectionId: number | string,
|
||||
options?: {
|
||||
enabled?: boolean
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
) {
|
||||
const page = options?.page || 1
|
||||
const limit = options?.limit || 6
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["collection", collectionId, "products-paginated", page, limit],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<PaginatedResponse<Product>>(
|
||||
`/collections/${collectionId}/products`,
|
||||
{
|
||||
params: {
|
||||
page,
|
||||
limit,
|
||||
},
|
||||
}
|
||||
)
|
||||
const data = response.data.data || []
|
||||
return {
|
||||
data,
|
||||
pagination: response.data.pagination || {},
|
||||
isEmpty: data.length === 0,
|
||||
}
|
||||
},
|
||||
enabled: options?.enabled !== false && !!collectionId,
|
||||
})
|
||||
}
|
||||
42
lib/hooks/useFavorites.ts
Normal file
42
lib/hooks/useFavorites.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import type { Favorite } from "@/lib/types/api"
|
||||
|
||||
export function useFavorites() {
|
||||
return useQuery({
|
||||
queryKey: ["favorites"],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<Favorite[]>("/favorites")
|
||||
return response.data
|
||||
},
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 1,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAddToFavorites() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (productId: number) => {
|
||||
const response = await apiClient.post<Favorite[]>("/favorites", { product_id: productId })
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["favorites"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRemoveFromFavorites() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (productId: number) => {
|
||||
await apiClient.delete(`/favorites/${productId}`)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["favorites"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
29
lib/hooks/useMedia.ts
Normal file
29
lib/hooks/useMedia.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import type { Carousel, Banner, PaginatedResponse } from "@/lib/types/api"
|
||||
|
||||
// Get all carousels
|
||||
export function useCarousels(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ["carousels"],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<PaginatedResponse<Carousel>>("/media/carousels")
|
||||
return response.data.data || response.data
|
||||
},
|
||||
enabled: options?.enabled !== false,
|
||||
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||
})
|
||||
}
|
||||
|
||||
// Get all banners
|
||||
export function useBanners(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ["banners"],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<PaginatedResponse<Banner>>("/media/banners")
|
||||
return response.data.data || response.data
|
||||
},
|
||||
enabled: options?.enabled !== false,
|
||||
staleTime: 1000 * 60 * 30, // 30 minutes
|
||||
})
|
||||
}
|
||||
38
lib/hooks/useOpenStore.ts
Normal file
38
lib/hooks/useOpenStore.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import { API_ENDPOINTS } from "@/lib/config/api-endpoints"
|
||||
|
||||
interface OpenStoreData {
|
||||
firstName: string
|
||||
lastName: string
|
||||
email: string
|
||||
phone: string
|
||||
patentFile: File
|
||||
}
|
||||
|
||||
interface OpenStoreResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export function useOpenStore() {
|
||||
return useMutation({
|
||||
mutationFn: async (data: OpenStoreData) => {
|
||||
const formData = new FormData()
|
||||
formData.append("first_name", data.firstName)
|
||||
formData.append("last_name", data.lastName)
|
||||
formData.append("email", data.email)
|
||||
formData.append("phone", data.phone)
|
||||
formData.append("patent_file", data.patentFile)
|
||||
|
||||
const response = await apiClient.post<OpenStoreResponse>(API_ENDPOINTS.openStore, formData, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
}
|
||||
59
lib/hooks/useOrders.ts
Normal file
59
lib/hooks/useOrders.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import type { Order, PaginatedResponse } from "@/lib/types/api"
|
||||
|
||||
export function useOrders(options?: { page?: number; perPage?: number }) {
|
||||
return useQuery({
|
||||
queryKey: ["orders", options?.page],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<PaginatedResponse<Order>>("/orders", {
|
||||
params: {
|
||||
page: options?.page || 1,
|
||||
per_page: options?.perPage || 20,
|
||||
},
|
||||
})
|
||||
return response.data.data || response.data
|
||||
},
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 1,
|
||||
})
|
||||
}
|
||||
|
||||
export function useOrder(id: number | string) {
|
||||
return useQuery({
|
||||
queryKey: ["order", id],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<Order>(`/orders/${id}`)
|
||||
return response.data
|
||||
},
|
||||
enabled: !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCancelOrder() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (orderId: number) => {
|
||||
await apiClient.post(`/orders/${orderId}/cancel`, {})
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["orders"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateOrder() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (orderData: any) => {
|
||||
const response = await apiClient.post<Order>("/orders", orderData)
|
||||
return response.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["orders"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["cart"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
14
lib/hooks/usePaymentTypes.ts
Normal file
14
lib/hooks/usePaymentTypes.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import type { PaymentTypeOption } from "@/lib/types/api"
|
||||
|
||||
export function usePaymentTypes() {
|
||||
return useQuery({
|
||||
queryKey: ["paymentTypes"],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<PaymentTypeOption[]>("/api/v1/order-payments")
|
||||
return response.data
|
||||
},
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
})
|
||||
}
|
||||
50
lib/hooks/useProducts.ts
Normal file
50
lib/hooks/useProducts.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import type { Product, PaginatedResponse } from "@/lib/types/api"
|
||||
|
||||
interface UseProductsOptions {
|
||||
enabled?: boolean
|
||||
staleTime?: number
|
||||
page?: number
|
||||
perPage?: number
|
||||
}
|
||||
|
||||
export function useProducts(options?: UseProductsOptions) {
|
||||
return useQuery({
|
||||
queryKey: ["products", options?.page, options?.perPage],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<PaginatedResponse<Product>>("/products", {
|
||||
params: {
|
||||
page: options?.page || 1,
|
||||
per_page: options?.perPage || 20,
|
||||
},
|
||||
})
|
||||
return response.data.data || response.data
|
||||
},
|
||||
staleTime: options?.staleTime ?? 1000 * 60 * 5, // 5 minutes
|
||||
enabled: options?.enabled !== false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useProduct(id: number | string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ["product", id],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<Product>(`/products/${id}`)
|
||||
return response.data
|
||||
},
|
||||
staleTime: 1000 * 60 * 10, // 10 minutes
|
||||
enabled: options?.enabled !== false && !!id,
|
||||
})
|
||||
}
|
||||
|
||||
export function useProductsBySlug(slug: string, options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ["products", "slug", slug],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<Product>(`/products/${slug}`)
|
||||
return response.data
|
||||
},
|
||||
enabled: options?.enabled !== false && !!slug,
|
||||
})
|
||||
}
|
||||
14
lib/hooks/useRegions.ts
Normal file
14
lib/hooks/useRegions.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import type { Region } from "@/lib/types/api"
|
||||
|
||||
export function useRegions() {
|
||||
return useQuery({
|
||||
queryKey: ["regions"],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<Region[]>("/api/v1/regions")
|
||||
return response.data
|
||||
},
|
||||
staleTime: 1000 * 60 * 60, // 1 hour
|
||||
})
|
||||
}
|
||||
28
lib/hooks/useSearch.ts
Normal file
28
lib/hooks/useSearch.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import type { SearchFilters, SearchResponse } from "@/lib/types/api"
|
||||
|
||||
export function useSearch(options: SearchFilters) {
|
||||
const { q, category_id, brand_id, price_from, price_to, page = 1, per_page = 20 } = options
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["search", { q, category_id, brand_id, price_from, price_to, page, per_page }],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(per_page),
|
||||
})
|
||||
|
||||
if (q) params.append("q", q)
|
||||
if (category_id) params.append("category_id", String(category_id))
|
||||
if (brand_id) params.append("brand_id", String(brand_id))
|
||||
if (price_from) params.append("price_from", String(price_from))
|
||||
if (price_to) params.append("price_to", String(price_to))
|
||||
|
||||
const response = await apiClient.get<SearchResponse>(`/search?${params}`)
|
||||
return response.data
|
||||
},
|
||||
enabled: !!q && q.length > 0,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
}
|
||||
18
lib/hooks/useUserProfile.ts
Normal file
18
lib/hooks/useUserProfile.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/lib/api"
|
||||
import { API_ENDPOINTS } from "@/lib/config/api-endpoints"
|
||||
import type { UserProfile } from "@/lib/types/api"
|
||||
|
||||
export function useUserProfile(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: ["user", "profile"],
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<UserProfile>(API_ENDPOINTS.profile)
|
||||
return response.data
|
||||
},
|
||||
staleTime: 1000 * 60 * 10, // 10 minutes
|
||||
enabled: options?.enabled !== false,
|
||||
})
|
||||
}
|
||||
29
lib/i18n-utils.ts
Normal file
29
lib/i18n-utils.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { useLocale } from "next-intl"
|
||||
|
||||
export function useLocaleInfo() {
|
||||
const locale = useLocale()
|
||||
|
||||
return {
|
||||
locale,
|
||||
isRussian: locale === "ru",
|
||||
isTurkmen: locale === "tm",
|
||||
}
|
||||
}
|
||||
|
||||
export function getLocaleFlag(locale: string) {
|
||||
const flags: Record<string, string> = {
|
||||
ru: "🇷🇺",
|
||||
tm: "🇹🇲",
|
||||
}
|
||||
return flags[locale] || "🌐"
|
||||
}
|
||||
|
||||
export function getLocaleName(locale: string) {
|
||||
const names: Record<string, string> = {
|
||||
ru: "Русский",
|
||||
tm: "Türkmençe",
|
||||
}
|
||||
return names[locale] || locale.toUpperCase()
|
||||
}
|
||||
46
lib/loading-utils.ts
Normal file
46
lib/loading-utils.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Debounce function for handling rapid state changes
|
||||
* @param func - Function to debounce
|
||||
* @param delay - Delay in milliseconds
|
||||
*/
|
||||
export function debounce<T extends (...args: any[]) => any>(func: T, delay: number): (...args: Parameters<T>) => void {
|
||||
let timeoutId: ReturnType<typeof setTimeout>
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => func(...args), delay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttle function for rate-limiting function calls
|
||||
* @param func - Function to throttle
|
||||
* @param limit - Minimum time between calls
|
||||
*/
|
||||
export function throttle<T extends (...args: any[]) => any>(func: T, limit: number): (...args: Parameters<T>) => void {
|
||||
let lastRun = 0
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
const now = Date.now()
|
||||
if (now - lastRun >= limit) {
|
||||
func(...args)
|
||||
lastRun = now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep utility for simulating delays
|
||||
* @param ms - Milliseconds to sleep
|
||||
*/
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate loading state
|
||||
* @param duration - Duration of loading state
|
||||
*/
|
||||
export async function simulateLoading(duration = 500): Promise<void> {
|
||||
return sleep(duration)
|
||||
}
|
||||
132
lib/mock-server/axios-adapter.ts
Normal file
132
lib/mock-server/axios-adapter.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Custom axios adapter for mocking API responses in development
|
||||
* Intercepts requests and routes them to mock handlers
|
||||
*/
|
||||
|
||||
import type { AxiosRequestConfig } from "axios"
|
||||
import { mockHandlers } from "./handlers"
|
||||
|
||||
interface MockRequest extends AxiosRequestConfig {
|
||||
url?: string
|
||||
}
|
||||
|
||||
export const createMockAdapter = () => {
|
||||
return async (config: MockRequest) => {
|
||||
const url = config.url || ""
|
||||
const method = (config.method || "get").toLowerCase()
|
||||
|
||||
try {
|
||||
if (method === "get") {
|
||||
if (url === "/products") {
|
||||
const response = await mockHandlers.getProducts()
|
||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
||||
}
|
||||
|
||||
if (url.match(/^\/products\/\d+$/)) {
|
||||
const id = Number.parseInt(url.split("/")[2])
|
||||
const response = await mockHandlers.getProduct(id)
|
||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
||||
}
|
||||
|
||||
if (url === "/categories") {
|
||||
const response = await mockHandlers.getCategories()
|
||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
||||
}
|
||||
|
||||
if (url.match(/^\/categories\/.+$/)) {
|
||||
const slug = url.split("/")[2]
|
||||
const response = await mockHandlers.getCategory(slug)
|
||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
||||
}
|
||||
|
||||
if (url === "/cart") {
|
||||
const response = await mockHandlers.getCart()
|
||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
||||
}
|
||||
|
||||
if (url === "/favorites") {
|
||||
const response = await mockHandlers.getFavorites()
|
||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
||||
}
|
||||
|
||||
if (url === "/orders") {
|
||||
const response = await mockHandlers.getOrders()
|
||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
||||
}
|
||||
|
||||
if (url.match(/^\/orders\/\d+$/)) {
|
||||
const id = Number.parseInt(url.split("/")[2])
|
||||
const response = await mockHandlers.getOrder(id)
|
||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
||||
}
|
||||
|
||||
if (url.match(/^\/search/)) {
|
||||
const params = new URLSearchParams(url.split("?")[1] || "")
|
||||
const query = params.get("q") || ""
|
||||
const response = await mockHandlers.search(query, {
|
||||
category: params.get("category") || undefined,
|
||||
priceFrom: params.get("priceFrom") ? Number.parseInt(params.get("priceFrom")!) : undefined,
|
||||
priceTo: params.get("priceTo") ? Number.parseInt(params.get("priceTo")!) : undefined,
|
||||
})
|
||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
||||
}
|
||||
}
|
||||
|
||||
if (method === "post") {
|
||||
if (url === "/cart/items") {
|
||||
const { productId, quantity } = config.data
|
||||
const response = await mockHandlers.addToCart(productId, quantity)
|
||||
return Promise.resolve({ data: response.data, status: 201, config, headers: {}, statusText: "Created" })
|
||||
}
|
||||
|
||||
if (url === "/favorites") {
|
||||
const { productId } = config.data
|
||||
const response = await mockHandlers.addToFavorites(productId)
|
||||
return Promise.resolve({ data: response.data, status: 201, config, headers: {}, statusText: "Created" })
|
||||
}
|
||||
|
||||
if (url.match(/^\/orders\/\d+\/cancel$/)) {
|
||||
const orderId = Number.parseInt(url.split("/")[2])
|
||||
const response = await mockHandlers.cancelOrder(orderId)
|
||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
||||
}
|
||||
}
|
||||
|
||||
if (method === "patch") {
|
||||
if (url.match(/^\/cart\/items\/\d+$/)) {
|
||||
const itemId = Number.parseInt(url.split("/")[3])
|
||||
const { quantity } = config.data
|
||||
const response = await mockHandlers.updateCartItemQuantity(itemId, quantity)
|
||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
||||
}
|
||||
}
|
||||
|
||||
if (method === "delete") {
|
||||
if (url.match(/^\/cart\/items\/\d+$/)) {
|
||||
const itemId = Number.parseInt(url.split("/")[3])
|
||||
const response = await mockHandlers.removeFromCart(itemId)
|
||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
||||
}
|
||||
|
||||
if (url.match(/^\/favorites\/\d+$/)) {
|
||||
const productId = Number.parseInt(url.split("/")[2])
|
||||
const response = await mockHandlers.removeFromFavorites(productId)
|
||||
return Promise.resolve({ data: response.data, status: 200, config, headers: {}, statusText: "OK" })
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback - endpoint not mocked
|
||||
return Promise.reject({
|
||||
response: { status: 404, data: { message: "Endpoint not found" }, config },
|
||||
})
|
||||
} catch (error: any) {
|
||||
return Promise.reject({
|
||||
response: {
|
||||
status: 400,
|
||||
data: { message: error.message },
|
||||
config,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
95
lib/mock-server/data.ts
Normal file
95
lib/mock-server/data.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Mock data for development and testing
|
||||
*/
|
||||
|
||||
export const mockProducts = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Premium Wireless Headphones",
|
||||
price: 199.99,
|
||||
category: "electronics",
|
||||
image: "/wireless-headphones.png",
|
||||
description: "High-quality sound with noise cancellation",
|
||||
stock: 50,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Classic Analog Watch",
|
||||
price: 89.99,
|
||||
category: "accessories",
|
||||
image: "/analog-watch.png",
|
||||
description: "Timeless design with precision movement",
|
||||
stock: 30,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Portable Charger 20000mAh",
|
||||
price: 49.99,
|
||||
category: "electronics",
|
||||
image: "/portable-charger-lifestyle.png",
|
||||
description: "Fast charging technology with dual ports",
|
||||
stock: 100,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Leather Messenger Bag",
|
||||
price: 129.99,
|
||||
category: "accessories",
|
||||
image: "/leather-messenger-bag.png",
|
||||
description: "Premium leather construction",
|
||||
stock: 25,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "4K Webcam",
|
||||
price: 149.99,
|
||||
category: "electronics",
|
||||
image: "/4k-webcam.jpg",
|
||||
description: "Professional quality streaming camera",
|
||||
stock: 40,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "USB-C Hub 7-in-1",
|
||||
price: 59.99,
|
||||
category: "electronics",
|
||||
image: "/usb-c-hub.jpg",
|
||||
description: "Multiple ports for connectivity",
|
||||
stock: 75,
|
||||
},
|
||||
]
|
||||
|
||||
export const mockCategories = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Electronics",
|
||||
slug: "electronics",
|
||||
image: "/electronics-category.png",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Accessories",
|
||||
slug: "accessories",
|
||||
image: "/accessories-category.png",
|
||||
},
|
||||
]
|
||||
|
||||
export const mockOrders = [
|
||||
{
|
||||
id: 101,
|
||||
status: "delivered" as const,
|
||||
total: 299.98,
|
||||
createdAt: new Date("2024-11-01").toISOString(),
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
status: "processing" as const,
|
||||
total: 199.99,
|
||||
createdAt: new Date("2024-11-05").toISOString(),
|
||||
},
|
||||
]
|
||||
|
||||
export const mockFavorites = [
|
||||
{ id: 1, productId: 1, addedAt: new Date().toISOString() },
|
||||
{ id: 2, productId: 3, addedAt: new Date().toISOString() },
|
||||
]
|
||||
181
lib/mock-server/handlers.ts
Normal file
181
lib/mock-server/handlers.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Mock HTTP handlers for development
|
||||
* Simulates API responses for TanStack Query testing
|
||||
*/
|
||||
|
||||
import { mockProducts, mockCategories, mockOrders, mockFavorites } from "./data"
|
||||
|
||||
interface MockCart {
|
||||
id: string
|
||||
items: Array<{ id: number; productId: number; quantity: number; price: number }>
|
||||
total: number
|
||||
}
|
||||
|
||||
// In-memory storage for development
|
||||
const mockCart: MockCart = {
|
||||
id: "cart-1",
|
||||
items: [],
|
||||
total: 0,
|
||||
}
|
||||
|
||||
let mockUserFavorites = [...mockFavorites]
|
||||
|
||||
/**
|
||||
* Simulate network delay for realistic testing
|
||||
*/
|
||||
export const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* Mock API handlers
|
||||
*/
|
||||
export const mockHandlers = {
|
||||
// Products
|
||||
async getProducts() {
|
||||
await delay(300)
|
||||
return { data: mockProducts }
|
||||
},
|
||||
|
||||
async getProduct(id: number) {
|
||||
await delay(300)
|
||||
const product = mockProducts.find((p) => p.id === id)
|
||||
if (!product) throw new Error("Product not found")
|
||||
return { data: product }
|
||||
},
|
||||
|
||||
// Categories
|
||||
async getCategories() {
|
||||
await delay(300)
|
||||
return { data: mockCategories }
|
||||
},
|
||||
|
||||
async getCategory(slug: string) {
|
||||
await delay(300)
|
||||
const category = mockCategories.find((c) => c.slug === slug)
|
||||
if (!category) throw new Error("Category not found")
|
||||
const products = mockProducts.filter((p) => p.category === slug)
|
||||
return { data: { ...category, products } }
|
||||
},
|
||||
|
||||
// Cart operations
|
||||
async getCart() {
|
||||
await delay(200)
|
||||
return { data: mockCart }
|
||||
},
|
||||
|
||||
async addToCart(productId: number, quantity = 1) {
|
||||
await delay(300)
|
||||
const product = mockProducts.find((p) => p.id === productId)
|
||||
if (!product) throw new Error("Product not found")
|
||||
|
||||
const existingItem = mockCart.items.find((item) => item.productId === productId)
|
||||
if (existingItem) {
|
||||
existingItem.quantity += quantity
|
||||
} else {
|
||||
mockCart.items.push({
|
||||
id: Date.now(),
|
||||
productId,
|
||||
quantity,
|
||||
price: product.price,
|
||||
})
|
||||
}
|
||||
|
||||
mockCart.total = mockCart.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
return { data: mockCart }
|
||||
},
|
||||
|
||||
async removeFromCart(itemId: number) {
|
||||
await delay(300)
|
||||
mockCart.items = mockCart.items.filter((item) => item.id !== itemId)
|
||||
mockCart.total = mockCart.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
|
||||
return { data: mockCart }
|
||||
},
|
||||
|
||||
async updateCartItemQuantity(itemId: number, quantity: number) {
|
||||
await delay(300)
|
||||
const item = mockCart.items.find((i) => i.id === itemId)
|
||||
if (!item) throw new Error("Item not found")
|
||||
item.quantity = Math.max(1, quantity)
|
||||
mockCart.total = mockCart.items.reduce((sum, i) => sum + i.price * i.quantity, 0)
|
||||
return { data: mockCart }
|
||||
},
|
||||
|
||||
// Favorites
|
||||
async getFavorites() {
|
||||
await delay(200)
|
||||
return { data: mockUserFavorites }
|
||||
},
|
||||
|
||||
async addToFavorites(productId: number) {
|
||||
await delay(300)
|
||||
const product = mockProducts.find((p) => p.id === productId)
|
||||
if (!product) throw new Error("Product not found")
|
||||
|
||||
const exists = mockUserFavorites.find((f) => f.productId === productId)
|
||||
if (!exists) {
|
||||
mockUserFavorites.push({
|
||||
id: Date.now(),
|
||||
productId,
|
||||
addedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
return { data: mockUserFavorites }
|
||||
},
|
||||
|
||||
async removeFromFavorites(productId: number) {
|
||||
await delay(300)
|
||||
mockUserFavorites = mockUserFavorites.filter((f) => f.productId !== productId)
|
||||
return { data: mockUserFavorites }
|
||||
},
|
||||
|
||||
// Orders
|
||||
async getOrders() {
|
||||
await delay(300)
|
||||
return { data: mockOrders }
|
||||
},
|
||||
|
||||
async getOrder(id: number) {
|
||||
await delay(300)
|
||||
const order = mockOrders.find((o) => o.id === id)
|
||||
if (!order) throw new Error("Order not found")
|
||||
return { data: order }
|
||||
},
|
||||
|
||||
async cancelOrder(orderId: number) {
|
||||
await delay(300)
|
||||
const order = mockOrders.find((o) => o.id === orderId)
|
||||
if (!order) throw new Error("Order not found")
|
||||
if (order.status !== "processing" && order.status !== "pending") {
|
||||
throw new Error("Cannot cancel shipped or delivered orders")
|
||||
}
|
||||
order.status = "cancelled"
|
||||
return { data: order }
|
||||
},
|
||||
|
||||
// Search
|
||||
async search(query: string, filters?: { category?: string; priceFrom?: number; priceTo?: number }) {
|
||||
await delay(400)
|
||||
let results = mockProducts
|
||||
|
||||
if (query) {
|
||||
results = results.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(query.toLowerCase()),
|
||||
)
|
||||
}
|
||||
|
||||
if (filters?.category) {
|
||||
results = results.filter((p) => p.category === filters.category)
|
||||
}
|
||||
|
||||
if (filters?.priceFrom) {
|
||||
results = results.filter((p) => p.price >= filters.priceFrom!)
|
||||
}
|
||||
|
||||
if (filters?.priceTo) {
|
||||
results = results.filter((p) => p.price <= filters.priceTo!)
|
||||
}
|
||||
|
||||
return { data: { products: results, total: results.length } }
|
||||
},
|
||||
}
|
||||
19
lib/queryClient.ts
Normal file
19
lib/queryClient.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { QueryClient } from "@tanstack/react-query"
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
|
||||
retry: 1,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: true,
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
|
||||
},
|
||||
},
|
||||
})
|
||||
195
lib/types/api.ts
Normal file
195
lib/types/api.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* API Response and Entity Type Definitions
|
||||
* Based on Postman collection structure
|
||||
*/
|
||||
|
||||
// Product Types
|
||||
export interface Product {
|
||||
id: number
|
||||
name: string
|
||||
slug?: string
|
||||
price: number
|
||||
description: string
|
||||
image: string
|
||||
images?: string[]
|
||||
category: string
|
||||
brand?: string
|
||||
stock?: number
|
||||
rating?: number
|
||||
reviews_count?: number
|
||||
is_favorite?: boolean
|
||||
is_in_cart?: boolean
|
||||
labels?: Array<{ text: string; bg_color: string }>
|
||||
struct_price_text?: string
|
||||
}
|
||||
|
||||
// Category Types
|
||||
export interface Category {
|
||||
id: number
|
||||
name: string
|
||||
slug: string
|
||||
image: string
|
||||
parent_id?: number
|
||||
children?: Category[]
|
||||
}
|
||||
|
||||
// Cart Types
|
||||
export interface CartItem {
|
||||
id: number
|
||||
product_id: number
|
||||
product?: Product
|
||||
seller: {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
quantity: number
|
||||
price: number
|
||||
total: number
|
||||
price_formatted?: string
|
||||
sub_total_formatted?: string
|
||||
}
|
||||
|
||||
export interface Cart {
|
||||
id: string
|
||||
items: CartItem[]
|
||||
total: number
|
||||
total_formatted?: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
// Favorites Types
|
||||
export interface Favorite {
|
||||
id: number
|
||||
product_id: number
|
||||
product?: Product
|
||||
added_at?: string
|
||||
}
|
||||
|
||||
// Order Types
|
||||
export interface OrderItem {
|
||||
id: number
|
||||
product_id: number
|
||||
product?: Product
|
||||
quantity: number
|
||||
price: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: number
|
||||
number?: string
|
||||
status: "pending" | "processing" | "shipped" | "delivered" | "cancelled"
|
||||
items: OrderItem[]
|
||||
total: number
|
||||
total_formatted?: string
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
estimated_delivery?: string
|
||||
tracking_number?: string
|
||||
}
|
||||
|
||||
// Pagination Types
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
pagination: {
|
||||
current_page: number
|
||||
last_page: number
|
||||
per_page: number
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
// Search Types
|
||||
export interface SearchFilters {
|
||||
q?: string
|
||||
category_id?: number
|
||||
brand_id?: number
|
||||
price_from?: number
|
||||
price_to?: number
|
||||
page?: number
|
||||
per_page?: number
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
products: Product[]
|
||||
total: number
|
||||
filters?: {
|
||||
brands: Array<{ id: number; name: string }>
|
||||
categories: Array<{ id: number; name: string }>
|
||||
price_range: { min: number; max: number }
|
||||
}
|
||||
}
|
||||
|
||||
// Profile Types
|
||||
export interface UserProfile {
|
||||
id: number
|
||||
email: string
|
||||
phone?: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
avatar?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// Auth Types
|
||||
export interface AuthResponse {
|
||||
token: string
|
||||
user: UserProfile
|
||||
}
|
||||
|
||||
// Banner Types
|
||||
export interface Banner {
|
||||
id: number
|
||||
title: string
|
||||
image: string
|
||||
url?: string
|
||||
type?: string
|
||||
place?: string
|
||||
}
|
||||
|
||||
// Generic API Error Response
|
||||
export interface ApiError {
|
||||
message: string
|
||||
errors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
// Region, Address, PaymentType, and ShippingMethod Types
|
||||
export interface Region {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
id: number
|
||||
title: string
|
||||
region_id: number
|
||||
address: string
|
||||
phone?: string
|
||||
is_default?: boolean
|
||||
}
|
||||
|
||||
export interface PaymentTypeOption {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface ShippingMethod {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
}
|
||||
|
||||
// Order creation payload type
|
||||
export interface CreateOrderPayload {
|
||||
customer_name?: string
|
||||
customer_phone?: string
|
||||
customer_address: string
|
||||
shipping_method: string
|
||||
payment_type_id: number
|
||||
delivery_time?: string
|
||||
delivery_at?: string
|
||||
region: string
|
||||
note?: string
|
||||
}
|
||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
76
lib/utils/error-handler.ts
Normal file
76
lib/utils/error-handler.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Centralized error handling utility
|
||||
* Converts API errors to user-friendly messages
|
||||
*/
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
message?: string
|
||||
errors?: Record<string, string[]>
|
||||
status?: number
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: any): string {
|
||||
if (!error) return "An unexpected error occurred"
|
||||
|
||||
// Axios error
|
||||
if (error.response?.data?.message) {
|
||||
return error.response.data.message
|
||||
}
|
||||
|
||||
if (error.response?.status === 401) {
|
||||
return "Please log in to continue"
|
||||
}
|
||||
|
||||
if (error.response?.status === 403) {
|
||||
return "You don't have permission to perform this action"
|
||||
}
|
||||
|
||||
if (error.response?.status === 404) {
|
||||
return "The requested resource was not found"
|
||||
}
|
||||
|
||||
if (error.response?.status === 500) {
|
||||
return "Server error occurred. Please try again later"
|
||||
}
|
||||
|
||||
if (error.message === "Network Error") {
|
||||
return "Network connection error. Please check your internet connection"
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return error
|
||||
}
|
||||
|
||||
return "An error occurred. Please try again"
|
||||
}
|
||||
|
||||
export function getValidationErrors(error: any): Record<string, string> {
|
||||
if (error.response?.data?.errors && typeof error.response.data.errors === "object") {
|
||||
const errors: Record<string, string> = {}
|
||||
for (const [key, messages] of Object.entries(error.response.data.errors)) {
|
||||
errors[key] = Array.isArray(messages) ? messages[0] : String(messages)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export function isNetworkError(error: any): boolean {
|
||||
return error?.message === "Network Error" || !error?.response
|
||||
}
|
||||
|
||||
export function isUnauthorized(error: any): boolean {
|
||||
return error?.response?.status === 401
|
||||
}
|
||||
|
||||
export function isForbidden(error: any): boolean {
|
||||
return error?.response?.status === 403
|
||||
}
|
||||
|
||||
export function isNotFound(error: any): boolean {
|
||||
return error?.response?.status === 404
|
||||
}
|
||||
|
||||
export function isServerError(error: any): boolean {
|
||||
return error?.response?.status >= 500
|
||||
}
|
||||
21
lib/utils/loading-states.ts
Normal file
21
lib/utils/loading-states.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Loading state utilities for better UX
|
||||
*/
|
||||
|
||||
export const loadingMessages = {
|
||||
fetching: "Loading...",
|
||||
submitting: "Processing...",
|
||||
deleting: "Deleting...",
|
||||
updating: "Updating...",
|
||||
saving: "Saving...",
|
||||
cart: "Adding to cart...",
|
||||
checkout: "Processing order...",
|
||||
} as const
|
||||
|
||||
export const skeletonCounts = {
|
||||
products: 10,
|
||||
categories: 6,
|
||||
cartItems: 3,
|
||||
orders: 6,
|
||||
reviews: 4,
|
||||
} as const
|
||||
Reference in New Issue
Block a user