fixed some bugs

This commit is contained in:
Jelaletdin12
2025-12-18 23:19:45 +05:00
parent 6d0064b106
commit 0fb4e2765c
36 changed files with 1430 additions and 1485 deletions

BIN
api.zip

Binary file not shown.

View File

@@ -105,7 +105,7 @@ export default function CartPage() {
const orderData = userStore.getOrderData();
if (!orderData) {
console.error("User data not found");
router.push("/login");
router.push("/");
return;
}
@@ -114,7 +114,7 @@ export default function CartPage() {
customer_name: name,
customer_phone: phone,
customer_address: selectedProvinceData.name,
shipping_method: deliveryType === "PICK_UP" ? "pickup" : "standart",
shipping_method: "standart",
payment_type_id: paymentType.id,
region: selectedRegion,
note: note || undefined,
@@ -138,12 +138,12 @@ export default function CartPage() {
}
return (
<div className="container mx-auto px-2 md:px-4 lg:px-6 mb-18">
<h1 className="text-3xl font-bold mb-6 pt-3">{t("cart")}</h1>
<div className=" mx-auto px-2 md:px-4 lg:px-6 mb-18">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold mb-4 md:mb-6 pt-3">{t("cart")}</h1>
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1">
<Card className="p-6 rounded-xl">
<Card className="p-4 md:p-6 rounded-xl">
{Object.entries(itemsBySeller).map(
([sellerId, { seller, items }]) => (
<div key={sellerId} className="mb-6">

View File

@@ -12,7 +12,7 @@ export default function FavoritesPage() {
if (isLoading) {
return (
<div className="container mx-auto px-4 py-8 min-h-screen">
<div className=" mx-auto px-4 py-8 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{t("favorite_products")}</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{Array.from({ length: 10 }).map((_, i) => (
@@ -30,7 +30,7 @@ export default function FavoritesPage() {
}
return (
<div className="container mx-auto px-2 md:px-4 lg:px-6 pb-12 space-y-8 max-w-[1504px]
<div className=" mx-auto px-2 md:px-4 lg:px-6 pb-12 space-y-8 max-w-[1504px]
">
<h1 className="bg-white text-3xl p-4 font-bold mb-0 pb-6">{t("favorite_products")}</h1>
<div className="bg-white grid grid-cols-2 sm:grid-cols-3 rounded-b-lg md:grid-cols-4 lg:grid-cols-5 gap-3 p-4">

View File

@@ -14,7 +14,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { useOpenStore } from "@/lib/hooks";
import { useToast } from "@/hooks/use-toast";
import { toast } from "sonner";
interface OpenStorePageProps {
locale?: string;
@@ -68,7 +68,7 @@ export default function OpenStorePage({
const [fileName, setFileName] = useState("");
const { mutate: submitOpenStore, isPending: loading } = useOpenStore();
const { toast } = useToast();
const t = translations || {
title: "Форма подачи заявления на открытие магазина",
@@ -160,10 +160,9 @@ export default function OpenStorePage({
},
{
onSuccess: () => {
toast({
title: "Success",
description: "Your store request has been submitted successfully",
});
toast.success("Your store request has been submitted successfully");
setFormData({
firstName: "",
lastName: "",
@@ -174,11 +173,7 @@ export default function OpenStorePage({
setFileName("");
},
onError: (error: any) => {
toast({
title: "Error",
description: error?.message || "Failed to submit store request",
variant: "destructive",
});
toast.error(error?.message || "Failed to submit store request");
},
}
);

View File

@@ -27,9 +27,7 @@ export default function Header({ locale = "ru" }: HeaderProps) {
const [isLoginOpen, setIsLoginOpen] = useState(false);
const t = useTranslations();
const { isAuthenticated, isLoading } = useAuthStatus();
const { isAuthenticated } = useAuthStatus();
useEffect(() => {
setIsClient(true);
@@ -43,8 +41,6 @@ export default function Header({ locale = "ru" }: HeaderProps) {
}
}, [isAuthenticated, locale]);
const toggleCategoryMenu = useCallback(() => {
setIsCategoryOpen((prev) => !prev);
}, []);
@@ -53,14 +49,12 @@ export default function Header({ locale = "ru" }: HeaderProps) {
setIsCategoryOpen(false);
}, []);
if (!isClient) return null;
return (
<>
<header className="sticky top-0 z-50 w-full border-b bg-white shadow-sm">
<div className="container mx-auto px-4">
<div className=" mx-auto px-4">
<div className="flex h-16 items-center justify-between gap-3">
<Link href="/" className="shrink-0">
<div className="relative h-8 w-[180px]">
@@ -76,7 +70,7 @@ export default function Header({ locale = "ru" }: HeaderProps) {
<Button
onClick={toggleCategoryMenu}
className="hidden gap-2 rounded-lg font-bold sm:flex hover:bg-[#005bff] bg-[#005bff] text-white"
className="hidden gap-2 rounded-lg font-bold lg:flex hover:bg-[#005bff] bg-[#005bff] text-white"
size="lg"
>
{isCategoryOpen ? <X className="h-5 w-5" /> : <CategoryIcon />}
@@ -127,12 +121,10 @@ export default function Header({ locale = "ru" }: HeaderProps) {
<MobileBottomNav
locale={locale}
onLoginClick={() => {
console.log('[Header] Opening login dialog');
setIsLoginOpen(true);
}}
/>
/>
</>
);
}

View File

@@ -95,7 +95,7 @@ export default function MobileBottomNav({
return (
<>
{/* Mobile Bottom Navigation */}
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t shadow-lg md:hidden">
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t shadow-lg lg:hidden">
<div className="flex items-center justify-around h-16 px-2">
{/* Catalog Button */}
<Button

View File

@@ -112,7 +112,7 @@ const cartCount = useCartCount()
);
return (
<div className="hidden items-center gap-1 md:flex">
<div className="hidden items-center gap-1 lg:flex">
{/* Profile/Login Button with Dropdown */}
{authLoading ? (
<div className="h-10 w-24 animate-pulse bg-gray-200 rounded" />

View File

@@ -23,7 +23,7 @@ export default function CategoryMenu({ isOpen, onClose }: CategoryMenuProps) {
return (
<div className="fixed left-0 right-0 top-15 z-40 bg-white border-b shadow-lg max-w-[1504px] mx-auto">
<div className="container mx-auto px-4">
<div className=" mx-auto px-4">
<div className="flex">
<CategoryList
categories={categoryList}

View File

@@ -1,3 +1,5 @@
// components/AuthWrapper.tsx
"use client";
import { useEffect, type ReactNode } from "react";
@@ -5,6 +7,7 @@ import { useRouter, usePathname } from "next/navigation";
import { useAuthStatus, useGetGuestToken } from "@/lib/hooks/useAuth";
import { useUserProfile } from "@/features/profile/hooks/useUserProfile";
import Preloader from "@/components/PageLoader/PreLoader";
import TokenStorage from "@/lib/tokenStorage";
interface AuthWrapperProps {
children: ReactNode;
@@ -24,27 +27,23 @@ export default function AuthWrapper({
const { isAuthenticated, isLoading } = useAuthStatus();
const { mutate: getGuestToken, isPending: isGettingGuestToken } = useGetGuestToken();
// Login olmuş kullanıcı için profil bilgisini otomatik çek
// Fetch user profile only if authenticated
useUserProfile();
// Initialize guest token if needed
useEffect(() => {
if (isLoading) return;
const authToken = document.cookie
.split("; ")
.find(row => row.startsWith("authToken="));
const guestToken = document.cookie
.split("; ")
.find(row => row.startsWith("guestToken="));
if (!authToken && !guestToken && !isGettingGuestToken) {
if (!TokenStorage.hasAnyToken() && !isGettingGuestToken) {
getGuestToken();
}
}, [isLoading, getGuestToken, isGettingGuestToken]);
// Handle redirects
useEffect(() => {
if (isLoading || isGettingGuestToken) return;
// Redirect to login if auth required but not authenticated
if (requireAuth && !isAuthenticated) {
const redirect = redirectTo || `/${locale}/login`;
const returnUrl = pathname !== redirect ? `?returnUrl=${encodeURIComponent(pathname)}` : "";
@@ -58,9 +57,7 @@ export default function AuthWrapper({
}, [isAuthenticated, isLoading, requireAuth, pathname, router, locale, redirectTo, isGettingGuestToken]);
if (isLoading || (requireAuth && !isAuthenticated)) {
return (
<Preloader/>
);
return <Preloader />;
}
return <>{children}</>;

View File

@@ -1,300 +1,325 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import Image from "next/image"
import { Minus, Plus, Trash2, Loader2, AlertTriangle } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import Image from "next/image";
import { Minus, Plus, Trash2, Loader2, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { useUpdateCartItemQuantity, useRemoveFromCart } from "@/lib/hooks"
import { useTranslations } from "next-intl"
import type { CartItem } from "@/lib/types/api"
} from "@/components/ui/dialog";
import { useUpdateCartItemQuantity, useRemoveFromCart } from "@/lib/hooks";
import { useTranslations } from "next-intl";
import type { CartItem } from "@/lib/types/api";
interface CartItemCardProps {
item: CartItem
onUpdate?: () => void
item: CartItem;
onUpdate?: () => void;
}
// Session Storage Key
const PENDING_CART_UPDATES_KEY = 'pendingCartUpdates'
const PENDING_CART_UPDATES_KEY = "pendingCartUpdates";
interface PendingUpdate {
quantity: number
timestamp: number
retryCount: number
quantity: number;
timestamp: number;
retryCount: number;
}
export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
const t = useTranslations()
const t = useTranslations();
// Local UI State (Instant feedback)
const [localQuantity, setLocalQuantity] = useState(item.quantity)
const [localQuantity, setLocalQuantity] = useState(item.quantity);
// Sync State
const [isSyncing, setIsSyncing] = useState(false)
const [syncError, setSyncError] = useState(false)
const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState(false);
// Stock limit modal
const [showStockModal, setShowStockModal] = useState(false)
const [showStockModal, setShowStockModal] = useState(false);
// Refs
const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined)
const isRequestInFlightRef = useRef(false)
const pendingQuantityRef = useRef<number | null>(null)
const retryCountRef = useRef(0)
const retryTimerRef = useRef<NodeJS.Timeout | undefined>(undefined)
const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isRequestInFlightRef = useRef(false);
const pendingQuantityRef = useRef<number | null>(null);
const retryCountRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
// Function refs to solve circular dependency
const syncToServerRef = useRef<((quantity: number) => void) | null>(null)
const retrySyncRef = useRef<((quantity: number) => void) | null>(null)
const syncToServerRef = useRef<((quantity: number) => void) | null>(null);
const retrySyncRef = useRef<((quantity: number) => void) | null>(null);
const { mutate: updateQuantity } = useUpdateCartItemQuantity()
const { mutate: removeItem, isPending: isRemoving } = useRemoveFromCart()
const { mutate: updateQuantity } = useUpdateCartItemQuantity();
const { mutate: removeItem, isPending: isRemoving } = useRemoveFromCart();
// Get available stock
const availableStock = item.product.stock || 0
const availableStock = item.product.stock || 0;
// Initialize from server state
useEffect(() => {
setLocalQuantity(item.quantity)
}, [item.quantity])
setLocalQuantity(item.quantity);
}, [item.quantity]);
// Save to sessionStorage
const savePendingUpdate = useCallback((quantity: number) => {
const savePendingUpdate = useCallback(
(quantity: number) => {
try {
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY)
const pending: Record<number, PendingUpdate> = stored ? JSON.parse(stored) : {}
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY);
const pending: Record<number, PendingUpdate> = stored
? JSON.parse(stored)
: {};
pending[item.product_id] = {
quantity,
timestamp: Date.now(),
retryCount: retryCountRef.current
}
retryCount: retryCountRef.current,
};
sessionStorage.setItem(PENDING_CART_UPDATES_KEY, JSON.stringify(pending))
sessionStorage.setItem(
PENDING_CART_UPDATES_KEY,
JSON.stringify(pending)
);
} catch (error) {
console.error('Failed to save pending update:', error)
console.error("Failed to save pending update:", error);
}
}, [item.product_id])
},
[item.product_id]
);
// Remove from sessionStorage
const clearPendingUpdate = useCallback(() => {
try {
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY)
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY);
if (stored) {
const pending: Record<number, PendingUpdate> = JSON.parse(stored)
delete pending[item.product_id]
const pending: Record<number, PendingUpdate> = JSON.parse(stored);
delete pending[item.product_id];
if (Object.keys(pending).length === 0) {
sessionStorage.removeItem(PENDING_CART_UPDATES_KEY)
sessionStorage.removeItem(PENDING_CART_UPDATES_KEY);
} else {
sessionStorage.setItem(PENDING_CART_UPDATES_KEY, JSON.stringify(pending))
sessionStorage.setItem(
PENDING_CART_UPDATES_KEY,
JSON.stringify(pending)
);
}
}
} catch (error) {
console.error('Failed to clear pending update:', error)
console.error("Failed to clear pending update:", error);
}
}, [item.product_id])
}, [item.product_id]);
// Exponential backoff retry
const retrySync = useCallback((quantity: number) => {
const maxRetries = 4
const retryCount = retryCountRef.current
const maxRetries = 4;
const retryCount = retryCountRef.current;
if (retryCount >= maxRetries) {
setSyncError(true)
setIsSyncing(false)
return
setSyncError(true);
setIsSyncing(false);
return;
}
const delay = Math.min(1000 * Math.pow(2, retryCount), 16000) // Max 16s
retryCountRef.current++
const delay = Math.min(1000 * Math.pow(2, retryCount), 16000); // Max 16s
retryCountRef.current++;
retryTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(quantity)
}, delay)
}, [])
syncToServerRef.current?.(quantity);
}, delay);
}, []);
// Update ref
retrySyncRef.current = retrySync
retrySyncRef.current = retrySync;
// Sync to server
const syncToServer = useCallback((quantity: number) => {
const syncToServer = useCallback(
(quantity: number) => {
// If already syncing, queue this update
if (isRequestInFlightRef.current) {
pendingQuantityRef.current = quantity
return
pendingQuantityRef.current = quantity;
return;
}
// Mark as syncing
isRequestInFlightRef.current = true
setIsSyncing(true)
setSyncError(false)
isRequestInFlightRef.current = true;
setIsSyncing(true);
setSyncError(false);
if (quantity <= 0) {
removeItem(item.product_id, {
onSuccess: () => {
isRequestInFlightRef.current = false
setIsSyncing(false)
retryCountRef.current = 0
clearPendingUpdate()
onUpdate?.()
isRequestInFlightRef.current = false;
setIsSyncing(false);
retryCountRef.current = 0;
clearPendingUpdate();
onUpdate?.();
// Process queued update if any
if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current
pendingQuantityRef.current = null
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100)
const nextQuantity = pendingQuantityRef.current;
pendingQuantityRef.current = null;
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100);
}
},
onError: (error) => {
console.error('Remove failed:', error)
isRequestInFlightRef.current = false
retrySyncRef.current?.(quantity)
}
})
console.error("Remove failed:", error);
isRequestInFlightRef.current = false;
retrySyncRef.current?.(quantity);
},
});
} else {
updateQuantity(
{ productId: item.product_id, quantity },
{
onSuccess: () => {
isRequestInFlightRef.current = false
setIsSyncing(false)
retryCountRef.current = 0
clearPendingUpdate()
onUpdate?.()
isRequestInFlightRef.current = false;
setIsSyncing(false);
retryCountRef.current = 0;
clearPendingUpdate();
onUpdate?.();
// Process queued update if any
if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current
pendingQuantityRef.current = null
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100)
const nextQuantity = pendingQuantityRef.current;
pendingQuantityRef.current = null;
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100);
}
},
onError: (error) => {
console.error('Update failed:', error)
isRequestInFlightRef.current = false
console.error("Update failed:", error);
isRequestInFlightRef.current = false;
// Rollback on error after retries exhausted
if (retryCountRef.current >= 3) {
setLocalQuantity(item.quantity)
clearPendingUpdate()
setLocalQuantity(item.quantity);
clearPendingUpdate();
}
retrySyncRef.current?.(quantity)
retrySyncRef.current?.(quantity);
},
}
);
}
)
}
}, [item.product_id, item.quantity, updateQuantity, removeItem, onUpdate, clearPendingUpdate])
},
[
item.product_id,
item.quantity,
updateQuantity,
removeItem,
onUpdate,
clearPendingUpdate,
]
);
// Update ref
syncToServerRef.current = syncToServer
syncToServerRef.current = syncToServer;
// Load pending updates from sessionStorage on mount
useEffect(() => {
const loadPendingUpdates = () => {
try {
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY)
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY);
if (stored) {
const pending: Record<number, PendingUpdate> = JSON.parse(stored)
const productPending = pending[item.product_id]
const pending: Record<number, PendingUpdate> = JSON.parse(stored);
const productPending = pending[item.product_id];
if (productPending && productPending.quantity !== item.quantity) {
// Apply pending update
setLocalQuantity(productPending.quantity)
pendingQuantityRef.current = productPending.quantity
retryCountRef.current = productPending.retryCount
setLocalQuantity(productPending.quantity);
pendingQuantityRef.current = productPending.quantity;
retryCountRef.current = productPending.retryCount;
// Trigger sync after a short delay
setTimeout(() => syncToServerRef.current?.(productPending.quantity), 500)
setTimeout(
() => syncToServerRef.current?.(productPending.quantity),
500
);
}
}
} catch (error) {
console.error('Failed to load pending updates:', error)
}
console.error("Failed to load pending updates:", error);
}
};
loadPendingUpdates()
}, [item.product_id, item.quantity])
loadPendingUpdates();
}, [item.product_id, item.quantity]);
// Debounced sync
useEffect(() => {
// Clear existing timers
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
clearTimeout(debounceTimerRef.current);
}
// If local quantity matches server, no sync needed
if (localQuantity === item.quantity) {
return
return;
}
// Save to sessionStorage immediately
savePendingUpdate(localQuantity)
savePendingUpdate(localQuantity);
// Debounce the API call
debounceTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(localQuantity)
}, 800)
syncToServerRef.current?.(localQuantity);
}, 800);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
clearTimeout(debounceTimerRef.current);
}
}
}, [localQuantity, item.quantity, savePendingUpdate])
};
}, [localQuantity, item.quantity, savePendingUpdate]);
// Cleanup
useEffect(() => {
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
if (retryTimerRef.current) clearTimeout(retryTimerRef.current)
}
}, [])
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
};
}, []);
const handleQuantityIncrease = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
e.preventDefault();
e.stopPropagation();
// Check stock limit
if (localQuantity >= availableStock) {
setShowStockModal(true)
return
setShowStockModal(true);
return;
}
// Optimistic update (instant UI feedback)
setLocalQuantity(prev => prev + 1)
}
setLocalQuantity((prev) => prev + 1);
};
const handleQuantityDecrease = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
e.preventDefault();
e.stopPropagation();
if (localQuantity <= 1) {
handleDelete()
return
handleDelete();
return;
}
// Optimistic update (instant UI feedback)
setLocalQuantity(prev => prev - 1)
}
setLocalQuantity((prev) => prev - 1);
};
const handleDelete = () => {
setLocalQuantity(0)
clearPendingUpdate()
}
setLocalQuantity(0);
clearPendingUpdate();
};
const getImageSrc = () => {
if (item.product.image) return item.product.image
if (item.product.images && item.product.images.length > 0) return item.product.images[0]
return "/placeholder.svg"
}
if (item.product.image) return item.product.image;
if (item.product.images && item.product.images.length > 0)
return item.product.images[0];
return "/placeholder.svg";
};
return (
<>
@@ -302,11 +327,18 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex gap-4 flex-1">
<div className="relative w-[88px] h-[117px] rounded-xl border overflow-hidden flex-shrink-0">
<Image src={getImageSrc()} alt={item.product.name} fill className="object-contain" />
<Image
src={getImageSrc()}
alt={item.product.name}
fill
className="object-contain"
/>
</div>
<div className="flex flex-col gap-2">
<h3 className="font-semibold text-base">{item.product.name}</h3>
<p className="text-sm text-gray-600">{item.seller?.name || "Store"}</p>
<p className="text-sm text-gray-600">
{item.seller?.name || "Store"}
</p>
{availableStock <= 5 && (
<p className="text-xs text-orange-600 font-medium">
{t("only_left", { count: availableStock })}
@@ -327,16 +359,25 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold">
{t("unit_price")} <span className="text-primary">{item.price_formatted}</span>
{t("unit_price")}{" "}
<span className="text-primary">{item.price_formatted}</span>
</p>
{item.discount_formatted && item.discount_formatted !== "0 TMT" && (
<p className="text-sm font-semibold">{t("discount")} {item.discount_formatted}</p>
{item.discount_formatted &&
item.discount_formatted !== "0 TMT" && (
<p className="text-sm font-semibold">
{t("discount")} {item.discount_formatted}
</p>
)}
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{t("total_price")}</span>
<span className="text-sm font-semibold">
{t("total_price")}
</span>
<span className="bg-green-500 text-white px-3 py-1 rounded-lg font-semibold text-base">
{(parseFloat(item.product.price_amount || "0") * localQuantity).toFixed(2)} TMT
{(
parseFloat(item.product.price_amount || "0") * localQuantity
).toFixed(2)}{" "}
TMT
</span>
</div>
</div>
@@ -346,7 +387,9 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
variant="outline"
size="icon"
onClick={handleQuantityDecrease}
className={` cursor-pointerrounded-xl bg-blue-50 ${isSyncing ? 'opacity-70' : ''}`}
className={` cursor-pointerrounded-xl bg-blue-50 ${
isSyncing ? "opacity-70" : ""
}`}
>
<Minus className="h-4 w-4" />
</Button>
@@ -357,7 +400,10 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
<Loader2 className="h-3 w-3 animate-spin absolute -top-1 -right-3 text-blue-500" />
)}
{syncError && (
<span className="absolute -top-1 -right-3 h-2 w-2 bg-red-500 rounded-full" title="Sync error" />
<span
className="absolute -top-1 -right-3 h-2 w-2 bg-red-500 rounded-full"
title="Sync error"
/>
)}
</div>
@@ -365,9 +411,13 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
variant="outline"
size="icon"
onClick={handleQuantityIncrease}
disabled={localQuantity >= availableStock}
className={`rounded-xl cursor-pointer bg-blue-50 ${isSyncing ? 'opacity-70' : ''} ${
localQuantity >= availableStock ? 'opacity-50 cursor-not-allowed' : ''
// disabled={localQuantity >= availableStock}
className={`rounded-xl cursor-pointer bg-blue-50 ${
isSyncing ? "opacity-70" : ""
} ${
localQuantity >= availableStock
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<Plus className="h-4 w-4 text-[#007AFF]" />
@@ -392,7 +442,7 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
<DialogDescription className="text-center text-base pt-2">
{t("stock_limit_message", {
product: item.product.name,
stock: availableStock
stock: availableStock,
})}
</DialogDescription>
</DialogHeader>
@@ -407,5 +457,5 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
</DialogContent>
</Dialog>
</>
)
);
}

View File

@@ -1,27 +1,27 @@
import { Skeleton } from "@/components/ui/skeleton"
import { Card } from "@/components/ui/card"
// import { Skeleton } from "@/components/ui/skeleton"
// import { Card } from "@/components/ui/card"
export default function CartItemSkeleton() {
return (
<Card className="p-4 rounded-xl">
<div className="flex gap-4">
{/* Product Image */}
<Skeleton className="w-24 h-24 rounded-lg flex-shrink-0 bg-gray-200" />
// export default function CartItemSkeleton() {
// return (
// <Card className="p-4 rounded-xl">
// <div className="flex gap-4">
// {/* Product Image */}
// <Skeleton className="w-24 h-24 rounded-lg flex-shrink-0 bg-gray-200" />
{/* Product Info */}
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4 bg-gray-200" />
<Skeleton className="h-4 w-1/2 bg-gray-200" />
<Skeleton className="h-6 w-20 bg-gray-200 mt-2" />
</div>
// {/* Product Info */}
// <div className="flex-1 space-y-2">
// <Skeleton className="h-4 w-3/4 bg-gray-200" />
// <Skeleton className="h-4 w-1/2 bg-gray-200" />
// <Skeleton className="h-6 w-20 bg-gray-200 mt-2" />
// </div>
{/* Quantity Controls */}
<div className="flex items-center gap-2">
<Skeleton className="w-8 h-8 rounded-lg bg-gray-200" />
<Skeleton className="w-8 h-8 bg-gray-200" />
<Skeleton className="w-8 h-8 rounded-lg bg-gray-200" />
</div>
</div>
</Card>
)
}
// {/* Quantity Controls */}
// <div className="flex items-center gap-2">
// <Skeleton className="w-8 h-8 rounded-lg bg-gray-200" />
// <Skeleton className="w-8 h-8 bg-gray-200" />
// <Skeleton className="w-8 h-8 rounded-lg bg-gray-200" />
// </div>
// </div>
// </Card>
// )
// }

View File

@@ -91,9 +91,9 @@ export default function OrderSummary({
selectedRegion && selectedProvince && paymentType && phone && name;
return (
<Card className="w-full md:w-[380px] p-6 rounded-xl h-fit sticky top-20">
<Card className="w-full md:w-[380px] p-4 md:p-6 rounded-xl h-fit sticky top-20">
{/* Customer Information */}
<div className="mb-6">
<div className="">
<h3 className="text-lg font-semibold mb-3">
{t("customer_information")}
</h3>
@@ -107,7 +107,7 @@ export default function OrderSummary({
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder={t("name")}
className="rounded-xl"
className="rounded-lg"
/>
</div>
<div>
@@ -119,7 +119,7 @@ export default function OrderSummary({
value={lastName}
onChange={(e) => onLastNameChange(e.target.value)}
placeholder={t("last_name")}
className="rounded-xl"
className="rounded-lg"
/>
</div>
<div>
@@ -131,14 +131,14 @@ export default function OrderSummary({
value={phone}
onChange={(e) => onPhoneChange(e.target.value)}
placeholder={t("phone")}
className="rounded-xl"
className="rounded-lg"
/>
</div>
</div>
</div>
{/* Payment Type */}
<div className="mb-6">
<div className="">
<h3 className="text-lg font-semibold mb-3">{t("payment_type")}</h3>
<div className="flex gap-2">
{paymentTypes.map((type) => (
@@ -166,13 +166,13 @@ export default function OrderSummary({
</div>
{/* Delivery Type */}
<DeliveryTypeSelector
{/* <DeliveryTypeSelector
selectedType={deliveryType}
onSelect={onDeliveryTypeChange}
/>
/> */}
{/* Region Selection */}
<div className="mb-6">
<div className="">
<Label className="text-lg font-semibold mb-3 block">
{t("choose_region")}
</Label>
@@ -204,7 +204,7 @@ export default function OrderSummary({
{/* Province Selection */}
{selectedRegion && provincesForSelectedRegion.length > 0 && (
<div className="mb-6">
<div className="">
<Label className="text-lg font-semibold mb-3 block">
{t("choose_address")}
</Label>
@@ -212,7 +212,7 @@ export default function OrderSummary({
value={selectedProvince?.toString() || ""}
onValueChange={(value) => onProvinceChange(parseInt(value))}
>
<SelectTrigger className="rounded-xl">
<SelectTrigger className="rounded-lg w-full">
<SelectValue placeholder={t("choose_address")} />
</SelectTrigger>
<SelectContent>
@@ -227,7 +227,7 @@ export default function OrderSummary({
)}
{/* Note */}
<div className="mb-6">
<div className="">
<Label className="text-lg font-semibold mb-3 block">{t("note")}</Label>
<Textarea
value={note}
@@ -253,7 +253,7 @@ export default function OrderSummary({
<Separator className="my-4" />
<div className="flex justify-between items-center mb-6">
<div className="flex justify-between items-center ">
<span className="text-lg font-semibold">
{order.billing.footer.title}:
</span>

View File

@@ -14,16 +14,30 @@ interface CartResponse {
errorDetails?: string;
}
// Event emitter for cross-component cart updates
// DEBUG: Enable detailed logging
const DEBUG = true;
const log = (...args: any[]) => {
if (DEBUG) console.log('[useCart]', ...args);
};
// CRITICAL: Single source of truth for pending updates
const pendingUpdates = new Map<number, number>(); // productId -> quantity
let updateLock = false;
class CartEventEmitter {
private listeners: Set<() => void> = new Set();
subscribe(callback: () => void) {
log('🔔 New subscriber added. Total:', this.listeners.size + 1);
this.listeners.add(callback);
return () => this.listeners.delete(callback);
return () => {
log('🔕 Subscriber removed. Total:', this.listeners.size - 1);
this.listeners.delete(callback);
};
}
emit() {
log('📢 Emitting cart event to', this.listeners.size, 'listeners');
this.listeners.forEach((cb) => cb());
}
}
@@ -36,31 +50,22 @@ function transformCartResponse(response: any): CartResponse {
(response.trim().startsWith("<!DOCTYPE") ||
response.trim().startsWith("<html"))
) {
console.error(
"Received HTML response instead of JSON:",
response.substring(0, 100)
);
return {
message: "error",
data: [],
errorDetails:
"Server returned HTML instead of JSON. The server might be down or experiencing issues.",
errorDetails: "Server returned HTML instead of JSON.",
};
}
if (typeof response === "object") {
if (response.data) {
return response;
}
if (response.data) return response;
return { message: "success", data: [] };
}
if (typeof response === "string") {
try {
const parsed = JSON.parse(response);
return parsed;
} catch (error) {
console.error("Failed to parse response:", error);
return JSON.parse(response);
} catch {
return { message: "error", data: [] };
}
}
@@ -71,34 +76,54 @@ function transformCartResponse(response: any): CartResponse {
export function useCart(options?: Partial<UseQueryOptions<CartResponse>>) {
const queryClient = useQueryClient();
log('🎣 useCart hook called with options:', options);
const query = useQuery({
queryKey: ["cart"],
queryFn: async () => {
log('🌐 Fetching cart from API...');
const response = await apiClient.get("/carts");
return transformCartResponse(response.data);
const transformed = transformCartResponse(response.data);
log('✅ Cart fetched:', {
itemCount: transformed.data.length,
items: transformed.data.map(item => ({
productId: item.product?.id,
quantity: item.product_quantity
}))
});
return transformed;
},
// REMOVED: Aggressive polling
// ADDED: Smart refetching only when needed
refetchOnMount: false, // Don't refetch on every mount
refetchOnWindowFocus: false, // Don't refetch on tab focus
refetchOnReconnect: true, // Only refetch on reconnect
staleTime: Infinity, // Data never goes stale automatically
gcTime: 1000 * 60 * 5, // Cache for 5 minutes
// CRITICAL FIX: Merge options AFTER defaults
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
staleTime: Infinity,
gcTime: 1000 * 60 * 5,
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
// User options OVERRIDE defaults
...options,
});
// Subscribe to cart events for cross-component updates
log('🔧 Query config after merge:', {
refetchOnMount: query.refetch !== undefined,
staleTime: query.isStale,
dataUpdatedAt: query.dataUpdatedAt
});
useEffect(() => {
log('🔗 Setting up cart events listener in useCart');
const unsubscribe = cartEvents.subscribe(() => {
// Only update cache, don't refetch
log('📥 Cart event received in useCart, invalidating query');
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "none",
});
});
return unsubscribe;
return () => {
log('🔌 Cleaning up cart events listener in useCart');
unsubscribe();
};
}, [queryClient]);
return query;
@@ -115,6 +140,7 @@ export function useAddToCart() {
productId: number;
quantity?: number;
}) => {
log(' AddToCart mutation:', { productId, quantity });
const params = new URLSearchParams({
product_id: String(productId),
product_quantity: String(quantity),
@@ -132,10 +158,8 @@ export function useAddToCart() {
if (typeof response.data === "string") {
try {
const parsed = JSON.parse(response.data);
return parsed;
} catch (error) {
console.error("Failed to parse add to cart response:", error);
return JSON.parse(response.data);
} catch {
return { message: "success", data: "Added to cart" };
}
}
@@ -143,66 +167,89 @@ export function useAddToCart() {
return { message: "success", data: "Added to cart" };
},
onMutate: async ({ productId, quantity }) => {
// Cancel outgoing refetches
log('🔒 AddToCart onMutate - Waiting for lock...');
while (updateLock) {
await new Promise(resolve => setTimeout(resolve, 50));
}
updateLock = true;
log('🔓 Lock acquired');
await queryClient.cancelQueries({ queryKey: ["cart"] });
// Snapshot previous value
const previousCart = queryClient.getQueryData<CartResponse>(["cart"]);
log('📸 Previous cart state:', previousCart?.data.length, 'items');
// Optimistically update cart
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
if (!old) return old;
const existingItem = old.data.find(
let updated = { ...old, data: [...old.data] };
pendingUpdates.forEach((pendingQty, pendingId) => {
const idx = updated.data.findIndex(
(item: any) => item.product?.id === pendingId
);
if (idx !== -1) {
updated.data[idx] = {
...updated.data[idx],
product_quantity: pendingQty,
};
}
});
const existingItem = updated.data.find(
(item: any) => item.product?.id === productId
);
if (existingItem) {
// Update existing item quantity
return {
...old,
data: old.data.map((item: any) =>
updated.data = updated.data.map((item: any) =>
item.product?.id === productId
? {
...item,
product_quantity: item.product_quantity + quantity,
}
: item
),
};
);
} else {
// Add new item (we don't have full product data, so we add placeholder)
return {
...old,
data: [
...old.data,
updated.data = [
...updated.data,
{
product: { id: productId },
product_quantity: quantity,
} as any,
],
};
];
}
const finalItem = updated.data.find(
(item: any) => item.product?.id === productId
);
if (finalItem) {
pendingUpdates.set(productId, finalItem.product_quantity);
log('💾 Pending update saved:', productId, '→', finalItem.product_quantity);
}
log('🔄 Cart updated optimistically:', updated.data.length, 'items');
return updated;
});
// Notify other components
cartEvents.emit();
updateLock = false;
return { previousCart };
},
onError: (error, variables, context) => {
// Rollback on error
log('❌ AddToCart error:', error);
if (context?.previousCart) {
queryClient.setQueryData(["cart"], context.previousCart);
pendingUpdates.delete(variables.productId);
cartEvents.emit();
}
console.error("Add to cart error:", error);
},
onSuccess: () => {
// Silently refetch in background to sync with server
onSuccess: (data, variables) => {
log('✅ AddToCart success');
pendingUpdates.delete(variables.productId);
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "active", // Only refetch if actively being watched
refetchType: "active",
});
},
});
@@ -213,6 +260,7 @@ export function useRemoveFromCart() {
return useMutation({
mutationFn: async (productId: number) => {
log('🗑️ RemoveFromCart mutation:', productId);
const params = new URLSearchParams({ product_id: String(productId) });
const response = await apiClient.patch("/carts", params.toString(), {
@@ -229,8 +277,7 @@ export function useRemoveFromCart() {
try {
const parsed = JSON.parse(response.data);
return parsed.data || [];
} catch (error) {
console.error("Failed to parse cart response:", error);
} catch {
return [];
}
}
@@ -238,30 +285,56 @@ export function useRemoveFromCart() {
return [];
},
onMutate: async (productId) => {
while (updateLock) {
await new Promise(resolve => setTimeout(resolve, 50));
}
updateLock = true;
await queryClient.cancelQueries({ queryKey: ["cart"] });
const previousCart = queryClient.getQueryData<CartResponse>(["cart"]);
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
if (!old) return old;
return {
...old,
data: old.data.filter((item: any) => item.product?.id !== productId),
let updated = { ...old, data: [...old.data] };
pendingUpdates.forEach((pendingQty, pendingId) => {
if (pendingId !== productId) {
const idx = updated.data.findIndex(
(item: any) => item.product?.id === pendingId
);
if (idx !== -1) {
updated.data[idx] = {
...updated.data[idx],
product_quantity: pendingQty,
};
}
}
});
updated.data = updated.data.filter(
(item: any) => item.product?.id !== productId
);
pendingUpdates.delete(productId);
log('🗑️ Item removed optimistically:', productId);
return updated;
});
cartEvents.emit();
updateLock = false;
return { previousCart };
},
onError: (error, variables, context) => {
log('❌ RemoveFromCart error:', error);
if (context?.previousCart) {
queryClient.setQueryData(["cart"], context.previousCart);
cartEvents.emit();
}
console.error("Remove from cart error:", error);
},
onSuccess: () => {
log('✅ RemoveFromCart success');
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "active",
@@ -275,6 +348,7 @@ export function useCleanCart() {
return useMutation({
mutationFn: async () => {
log('🧹 CleanCart mutation');
const response = await apiClient.delete("/carts", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@@ -289,8 +363,7 @@ export function useCleanCart() {
try {
const parsed = JSON.parse(response.data);
return parsed.data || [];
} catch (error) {
console.error("Failed to parse cart response:", error);
} catch {
return [];
}
}
@@ -298,16 +371,23 @@ export function useCleanCart() {
return [];
},
onMutate: async () => {
while (updateLock) {
await new Promise(resolve => setTimeout(resolve, 50));
}
updateLock = true;
await queryClient.cancelQueries({ queryKey: ["cart"] });
const previousCart = queryClient.getQueryData<CartResponse>(["cart"]);
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
if (!old) return old;
pendingUpdates.clear();
return { ...old, data: [] };
});
cartEvents.emit();
updateLock = false;
return { previousCart };
},
@@ -334,6 +414,7 @@ export function useUpdateCartItemQuantity() {
productId: number;
quantity: number;
}) => {
log('🔄 UpdateQuantity mutation:', { productId, quantity });
const params = new URLSearchParams({
product_id: String(productId),
product_quantity: String(quantity),
@@ -352,10 +433,8 @@ export function useUpdateCartItemQuantity() {
if (typeof response.data === "string") {
try {
const parsed = JSON.parse(response.data);
return parsed;
} catch (error) {
console.error("Failed to parse update cart response:", error);
return JSON.parse(response.data);
} catch {
return { message: "success", data: "Updated cart" };
}
}
@@ -363,39 +442,68 @@ export function useUpdateCartItemQuantity() {
return { message: "success", data: "Updated cart" };
},
onMutate: async ({ productId, quantity }) => {
log('🔒 UpdateQuantity onMutate - Waiting for lock...');
while (updateLock) {
await new Promise(resolve => setTimeout(resolve, 50));
}
updateLock = true;
log('🔓 Lock acquired');
await queryClient.cancelQueries({ queryKey: ["cart"] });
const previousCart = queryClient.getQueryData<CartResponse>(["cart"]);
log('📸 Previous cart state:', previousCart?.data.length, 'items');
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
if (!old) return old;
return {
...old,
data: old.data.map((item: any) =>
let updated = { ...old, data: [...old.data] };
pendingUpdates.forEach((pendingQty, pendingId) => {
const idx = updated.data.findIndex(
(item: any) => item.product?.id === pendingId
);
if (idx !== -1) {
updated.data[idx] = {
...updated.data[idx],
product_quantity: pendingQty,
};
}
});
updated.data = updated.data.map((item: any) =>
item.product?.id === productId
? { ...item, product_quantity: quantity }
: item
),
};
);
pendingUpdates.set(productId, quantity);
log('💾 Pending update saved:', productId, '→', quantity);
log('🔄 Cart updated optimistically:', updated.data.length, 'items');
return updated;
});
cartEvents.emit();
updateLock = false;
return { previousCart };
},
onError: (error, variables, context) => {
log('❌ UpdateQuantity error:', error);
if (context?.previousCart) {
queryClient.setQueryData(["cart"], context.previousCart);
pendingUpdates.delete(variables.productId);
cartEvents.emit();
}
console.error("API update failed:", error);
throw error;
},
onSuccess: () => {
// Background sync
onSuccess: (data, variables) => {
log('✅ UpdateQuantity success');
pendingUpdates.delete(variables.productId);
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "none", // Don't refetch, trust optimistic update
refetchType: "none",
});
},
});
@@ -420,7 +528,7 @@ export function useCreateOrder() {
return response.data;
},
onSuccess: () => {
// Clear cart after successful order
pendingUpdates.clear();
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
if (!old) return old;
return { ...old, data: [] };
@@ -437,7 +545,6 @@ export function useCreateOrder() {
});
}
// Hook to get cart count for badges
export function useCartCount() {
const { data } = useCart();
return (

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useRef, useCallback, MouseEvent } from "react";
import { useRouter } from "next/navigation";
import { Heart, ShoppingCart, Loader2, Plus, Minus } from "lucide-react";
import { Heart, ShoppingCart, Loader2, Plus, Minus, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import {
Carousel,
@@ -15,6 +15,13 @@ import {
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useToggleFavorite, useIsFavorite } from "@/lib/hooks";
import {
useAddToCart,
@@ -65,6 +72,7 @@ export default function ProductCard({
const [current, setCurrent] = useState(0);
const [localQuantity, setLocalQuantity] = useState(1);
const [isSyncing, setIsSyncing] = useState(false);
const [showStockModal, setShowStockModal] = useState(false);
const autoplayRef = useRef<NodeJS.Timeout | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
@@ -77,7 +85,6 @@ export default function ProductCard({
const isOutOfStock = stock === 0;
const availableStock = stock || 999;
// Carousel setup
useEffect(() => {
if (!api) return;
setCurrent(api.selectedScrollSnap());
@@ -88,7 +95,6 @@ export default function ProductCard({
};
}, [api]);
// Autoplay
useEffect(() => {
if (!api || !hasMultipleImages) return;
@@ -101,12 +107,10 @@ export default function ProductCard({
};
}, [api, hasMultipleImages]);
// Sync local quantity with cart
useEffect(() => {
setLocalQuantity(cartItem?.product_quantity || 1);
}, [cartItem]);
// Server sync function
const syncToServer = useCallback(
async (quantity: number) => {
if (isRequestInFlightRef.current) {
@@ -140,7 +144,6 @@ export default function ProductCard({
[id, updateCartMutation, cartItem, refetchCart]
);
// Debounced sync
useEffect(() => {
if (!isInCart || localQuantity === (cartItem?.product_quantity || 1))
return;
@@ -180,13 +183,8 @@ export default function ProductCard({
e.preventDefault();
e.stopPropagation();
// Stock kontrolü
if (localQuantity > availableStock) {
toast.error("Insufficient Stock", {
description: `Only ${availableStock} items available in stock`,
duration: 4000,
});
setLocalQuantity(availableStock);
setShowStockModal(true);
return;
}
@@ -221,10 +219,7 @@ export default function ProductCard({
if (newQuantity < 1) return;
if (newQuantity > availableStock) {
toast.error("Stock Limit Reached", {
description: `Maximum ${availableStock} items available`,
duration: 4000,
});
setShowStockModal(true);
return;
}
@@ -233,16 +228,24 @@ export default function ProductCard({
[localQuantity, availableStock]
);
const handleCardClick = (e: MouseEvent<HTMLDivElement>) => {
const handleCardClick = useCallback((e: MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
// Prevent navigation if clicking on buttons or interactive elements
if (
target.closest("button") ||
target.closest('[data-carousel-control="true"]')
target.closest('[data-carousel-control="true"]') ||
target.closest('[role="dialog"]')
) {
e.preventDefault();
e.stopPropagation();
return;
}
// Programmatic navigation
e.preventDefault();
router.push(`/product/${id}`);
};
}, [router, id]);
const handleNavClick = (e: MouseEvent, action: () => void) => {
e.preventDefault();
@@ -251,6 +254,7 @@ export default function ProductCard({
};
return (
<>
<div
onClick={handleCardClick}
className="flex justify-center cursor-pointer"
@@ -404,7 +408,7 @@ export default function ProductCard({
variant="outline"
size="icon"
onClick={(e) => handleQuantityChange(e, 1)}
disabled={localQuantity >= availableStock || isSyncing}
disabled={isSyncing}
className="rounded-lg h-9 w-9 shrink-0"
>
<Plus className="h-4 w-4 text-[#005bff]" />
@@ -415,5 +419,38 @@ export default function ProductCard({
)}
</Card>
</div>
<Dialog open={showStockModal} onOpenChange={setShowStockModal}>
<DialogContent className="sm:max-w-md" onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<div className="flex items-center justify-center mb-4">
<div className="rounded-full bg-orange-100 p-3">
<AlertTriangle className="h-6 w-6 text-orange-600" />
</div>
</div>
<DialogTitle className="text-center text-xl">
{t("stock_limit_title")}
</DialogTitle>
<DialogDescription className="text-center text-base pt-2">
{t("stock_limit_message", {
product: name,
stock: availableStock,
})}
</DialogDescription>
</DialogHeader>
<div className="flex justify-center mt-4">
<Button
onClick={(e) => {
e.stopPropagation();
setShowStockModal(false);
}}
className="w-full rounded-lg"
>
{t("understood")}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -2,7 +2,7 @@
import { useRouter } from "next/navigation";
import { ChevronRight } from "lucide-react";
import ProductCard from "@/features/home/components/ProductCard";
import { useCollectionProducts } from "@/lib/hooks";
import { useCollectionProducts } from "@/features/collections/hooks/useCollections";
import type { Collection } from "@/lib/types/api";
type Props = {

View File

@@ -24,7 +24,7 @@ import {
CreditCard,
ShoppingBag,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { toast } from "sonner";
import { useOrders, useCancelOrder } from "@/lib/hooks";
import { useTranslations } from "next-intl";
import type { Order } from "@/lib/types/api";
@@ -37,7 +37,7 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
const [orderToCancel, setOrderToCancel] = useState<Order | null>(null);
const [expandedOrders, setExpandedOrders] = useState<Set<number>>(new Set());
const { toast } = useToast();
const t = useTranslations();
const { data: orders, isLoading, isError } = useOrders();
@@ -66,19 +66,12 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
cancelOrder(orderToCancel.id, {
onSuccess: () => {
toast({
title: t("order_cancelled"),
description: t("order_cancelled_description"),
});
toast.success(t("order_cancelled"));
setIsCancelDialogOpen(false);
setOrderToCancel(null);
},
onError: (error: any) => {
toast({
title: t("error"),
description: error.message || t("cancel_order_failed"),
variant: "destructive",
});
toast.error(error.message || t("cancel_order_failed"));
},
});
}, [orderToCancel, cancelOrder, toast, t]);
@@ -168,7 +161,7 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
if (isLoading) {
return (
<div className="container mx-auto p-4 min-h-screen">
<div className=" mx-auto p-4 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{t("my_orders")}</h1>
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
@@ -186,7 +179,7 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
}
return (
<div className="container mx-auto p-2 lg:p-6 md:p-4 mb-16 min-h-screen">
<div className=" mx-auto p-2 lg:p-6 md:p-4 mb-16 min-h-screen">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold mb-6">{t("my_orders")}</h1>
<Tabs defaultValue="active" className="w-full">
@@ -331,7 +324,7 @@ function CompactOrderCard({
</div>
<div className="flex items-center gap-4">
<div className="flex flex-col md:flex-row gap-2 ">
<div className="flex flex-col md:flex-row gap-2 items-end">
{getStatusBadge(order.status)}
<div className="text-right">
@@ -354,7 +347,7 @@ function CompactOrderCard({
<div className="border-t bg-white">
{/* Order Info Grid */}
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4 bg-gray-50">
<div className="flex items-start gap-3">
{/* <div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-blue-500 mt-0.5" />
<div>
<p className="text-sm font-medium text-gray-700">
@@ -365,7 +358,7 @@ function CompactOrderCard({
{order.delivery_time}
</p>
</div>
</div>
</div> */}
<div className="flex items-start gap-3">
<MapPin className="h-5 w-5 text-red-500 mt-0.5" />

View File

@@ -34,33 +34,33 @@ export function useOrder(id: number | string) {
});
}
export function useCreateOrder() {
const queryClient = useQueryClient();
// export function useCreateOrder() {
// const queryClient = useQueryClient();
return useMutation({
mutationFn: async (orderData: CreateOrderRequest) => {
const formData = new URLSearchParams();
// return useMutation({
// mutationFn: async (orderData: CreateOrderRequest) => {
// const formData = new URLSearchParams();
Object.entries(orderData).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, String(value));
}
});
// Object.entries(orderData).forEach(([key, value]) => {
// if (value !== null && value !== undefined) {
// formData.append(key, String(value));
// }
// });
const response = await apiClient.post("/orders", formData, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
// const response = await apiClient.post("/orders", formData, {
// headers: {
// "Content-Type": "application/x-www-form-urlencoded",
// },
// });
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["cart"] });
},
});
}
// return response.data;
// },
// onSuccess: () => {
// queryClient.invalidateQueries({ queryKey: ["orders"] });
// queryClient.invalidateQueries({ queryKey: ["cart"] });
// },
// });
// }
export function useCancelOrder() {
const queryClient = useQueryClient();

View File

@@ -1,225 +1,281 @@
"use client";
"use client"
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import {
useProductsBySlug,
useRelatedProducts,
useSubmitReview,
} from "@/features/products/hooks/useProducts";
import { useState, useCallback, useMemo, useRef, useEffect } from "react"
import { Skeleton } from "@/components/ui/skeleton"
import { useProductsBySlug, useRelatedProducts, useSubmitReview } from "@/features/products/hooks/useProducts"
import {
useAddToCart,
useUpdateCartItemQuantity,
useRemoveFromCart,
useCart,
} from "@/features/cart/hooks/useCart";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { ProductImageGallery } from "./ProductImageGallery";
import { ProductInfoCard } from "./ProductInfoCard";
import { ProductPurchaseCard } from "./ProductPurchaseCard";
import { ProductReviewsSection } from "./ProductReviewsSection";
import { RelatedProductsSection } from "./RelatedProductsSection";
import { ReviewModal } from "./ReviewModal";
import { StockLimitModal } from "./StockLimitModal";
cartEvents,
} from "@/features/cart/hooks/useCart"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { ProductImageGallery } from "./ProductImageGallery"
import { ProductInfoCard } from "./ProductInfoCard"
import { ProductPurchaseCard } from "./ProductPurchaseCard"
import { ProductReviewsSection } from "./ProductReviewsSection"
import { RelatedProductsSection } from "./RelatedProductsSection"
import { ReviewModal } from "./ReviewModal"
import { StockLimitModal } from "./StockLimitModal"
interface ProductDetailProps {
slug: string;
slug: string
}
const PENDING_PRODUCT_UPDATES_KEY = "pendingProductUpdates";
const PENDING_PRODUCT_UPDATES_KEY = "pendingProductUpdates"
interface PendingUpdate {
quantity: number;
timestamp: number;
retryCount: number;
quantity: number
timestamp: number
retryCount: number
}
const DEBUG = true
const log = (...args: any[]) => {
if (DEBUG) console.log("[ProductPage]", ...args)
}
export default function ProductPageContent({ slug }: ProductDetailProps) {
const [localQuantity, setLocalQuantity] = useState(1);
const [isFavorite, setIsFavorite] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState(false);
const [showStockModal, setShowStockModal] = useState(false);
const [showReviewModal, setShowReviewModal] = useState(false);
const [localQuantity, setLocalQuantity] = useState(1)
const [isFavorite, setIsFavorite] = useState(false)
const [isSyncing, setIsSyncing] = useState(false)
const [syncError, setSyncError] = useState(false)
const [showStockModal, setShowStockModal] = useState(false)
const [showReviewModal, setShowReviewModal] = useState(false)
const [isInitialized, setIsInitialized] = useState(false) // 🔥 NEW: Track initialization
const t = useTranslations();
const t = useTranslations()
const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isRequestInFlightRef = useRef(false);
const pendingQuantityRef = useRef<number | null>(null);
const retryCountRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const syncToServerRef = useRef<((quantity: number) => void) | null>(null);
const retrySyncRef = useRef<((quantity: number) => void) | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined)
const isRequestInFlightRef = useRef(false)
const pendingQuantityRef = useRef<number | null>(null)
const retryCountRef = useRef(0)
const retryTimerRef = useRef<NodeJS.Timeout | undefined>(undefined)
const syncToServerRef = useRef<((quantity: number) => void) | null>(null)
const retrySyncRef = useRef<((quantity: number) => void) | null>(null)
const shouldSyncFromCartRef = useRef(true)
const lastSyncedQuantityRef = useRef<number | null>(null)
const {
data: product,
isLoading: productLoading,
error,
refetch: refetchProduct,
} = useProductsBySlug(slug);
const { data: product, isLoading: productLoading, error, refetch: refetchProduct } = useProductsBySlug(slug)
const { data: cartData, refetch: refetchCart } = useCart();
// 🔥 FIX: Memoize cart options to prevent infinite re-subscriptions
const cartOptions = useMemo(
() => ({
refetchOnMount: true,
refetchOnWindowFocus: true,
staleTime: 0,
}),
[],
)
const { data: cartData, refetch: refetchCart, isFetching: isCartFetching } = useCart(cartOptions)
const { data: relatedProducts } = useRelatedProducts(product?.id || 0, {
enabled: !!product?.id,
});
})
const addToCartMutation = useAddToCart();
const updateCartMutation = useUpdateCartItemQuantity();
const removeFromCartMutation = useRemoveFromCart();
const submitReviewMutation = useSubmitReview();
const addToCartMutation = useAddToCart()
const updateCartMutation = useUpdateCartItemQuantity()
const removeFromCartMutation = useRemoveFromCart()
const submitReviewMutation = useSubmitReview()
const cartItem = useMemo(
() => cartData?.data?.find((item: any) => item.product?.id === product?.id),
[cartData, product]
);
const isInCart = !!cartItem;
const availableStock = product?.stock || 0;
const cartItem = useMemo(() => {
const item = cartData?.data?.find((item: any) => item.product?.id === product?.id)
log("🎯 Cart Item Found:", {
productId: product?.id,
cartItem: item,
quantity: item?.product_quantity,
isInitialized,
})
return item
}, [cartData, product, isInitialized])
const isInCart = !!cartItem
const availableStock = product?.stock || 0
log("📊 State:", {
isInCart,
localQuantity,
cartItemQuantity: cartItem?.product_quantity,
availableStock,
isSyncing,
shouldSyncFromCart: shouldSyncFromCartRef.current,
isInitialized,
})
const imageUrls = useMemo(
() =>
product?.media?.map(
(m) => m.images_800x800 || m.images_720x720 || m.thumbnail
) || [],
[product]
);
() => product?.media?.map((m) => m.images_800x800 || m.images_720x720 || m.thumbnail) || [],
[product],
)
// ✅ CORRECT - Use reviews from product data
const reviews = useMemo(() => product?.reviews_resources || [], [product]);
const reviews = useMemo(() => product?.reviews_resources || [], [product])
const averageRating = useMemo(
() => (product?.reviews?.rating ? parseFloat(product.reviews.rating) : 0),
[product]
);
() => (product?.reviews?.rating ? Number.parseFloat(product.reviews.rating) : 0),
[product],
)
// 🔥 FIX: Subscribe to cart events ONCE with stable dependencies
useEffect(() => {
log("🔔 Setting up cart event subscription")
const unsubscribe = cartEvents.subscribe(() => {
log("📢 Cart event received! Refetching...")
refetchCart()
})
return () => {
log("🔕 Cleaning up cart event subscription")
unsubscribe()
}
}, [refetchCart])
// 🔥 CRITICAL FIX: Initialize localQuantity from cart ONCE on mount
useEffect(() => {
if (!product?.id || isInitialized) return
log("🚀 Initializing component with product:", product.id)
if (cartItem?.product_quantity) {
const serverQuantity = cartItem.product_quantity
log("✅ Initial cart quantity found:", serverQuantity)
setLocalQuantity(serverQuantity)
lastSyncedQuantityRef.current = serverQuantity
}
setIsInitialized(true)
}, [product?.id, cartItem, isInitialized])
useEffect(() => {
if (cartItem?.product_quantity) {
setLocalQuantity(cartItem.product_quantity);
}
}, [cartItem]);
setLocalQuantity(cartItem?.product_quantity || 1)
}, [cartItem])
const savePendingUpdate = useCallback(
(quantity: number) => {
if (!product?.id) return;
if (!product?.id) return
try {
const stored = sessionStorage.getItem(PENDING_PRODUCT_UPDATES_KEY);
const pending: Record<number, PendingUpdate> = stored
? JSON.parse(stored)
: {};
const stored = sessionStorage.getItem(PENDING_PRODUCT_UPDATES_KEY)
const pending: Record<number, PendingUpdate> = stored ? JSON.parse(stored) : {}
pending[product.id] = {
quantity,
timestamp: Date.now(),
retryCount: retryCountRef.current,
};
sessionStorage.setItem(
PENDING_PRODUCT_UPDATES_KEY,
JSON.stringify(pending)
);
}
sessionStorage.setItem(PENDING_PRODUCT_UPDATES_KEY, JSON.stringify(pending))
} catch (error) {
console.error("Failed to save pending update:", error);
console.error("Failed to save pending update:", error)
}
},
[product?.id]
);
[product?.id],
)
const clearPendingUpdate = useCallback(() => {
if (!product?.id) return;
if (!product?.id) return
try {
const stored = sessionStorage.getItem(PENDING_PRODUCT_UPDATES_KEY);
const stored = sessionStorage.getItem(PENDING_PRODUCT_UPDATES_KEY)
if (stored) {
const pending: Record<number, PendingUpdate> = JSON.parse(stored);
delete pending[product.id];
const pending: Record<number, PendingUpdate> = JSON.parse(stored)
delete pending[product.id]
if (Object.keys(pending).length === 0) {
sessionStorage.removeItem(PENDING_PRODUCT_UPDATES_KEY);
sessionStorage.removeItem(PENDING_PRODUCT_UPDATES_KEY)
} else {
sessionStorage.setItem(
PENDING_PRODUCT_UPDATES_KEY,
JSON.stringify(pending)
);
sessionStorage.setItem(PENDING_PRODUCT_UPDATES_KEY, JSON.stringify(pending))
}
}
} catch (error) {
console.error("Failed to clear pending update:", error);
console.error("Failed to clear pending update:", error)
}
}, [product?.id]);
}, [product?.id])
const retrySync = useCallback(
(quantity: number) => {
const maxRetries = 4;
const retryCount = retryCountRef.current;
const maxRetries = 4
const retryCount = retryCountRef.current
if (retryCount >= maxRetries) {
setSyncError(true);
setIsSyncing(false);
setSyncError(true)
setIsSyncing(false)
shouldSyncFromCartRef.current = true
toast.error(t("error"), {
description: t("update_quantity_failed"),
});
return;
})
return
}
const delay = Math.min(1000 * Math.pow(2, retryCount), 16000);
retryCountRef.current++;
const delay = Math.min(1000 * Math.pow(2, retryCount), 16000)
retryCountRef.current++
retryTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(quantity);
}, delay);
syncToServerRef.current?.(quantity)
}, delay)
},
[t]
);
[t],
)
retrySyncRef.current = retrySync;
retrySyncRef.current = retrySync
const syncToServer = useCallback(
async (quantity: number) => {
if (!product?.id) return;
if (!product?.id) return
log("🚀 syncToServer called:", {
productId: product.id,
quantity,
isRequestInFlight: isRequestInFlightRef.current,
isInCart,
})
if (isRequestInFlightRef.current) {
pendingQuantityRef.current = quantity;
return;
log("⏳ Request in flight, queuing:", quantity)
pendingQuantityRef.current = quantity
return
}
isRequestInFlightRef.current = true;
setIsSyncing(true);
setSyncError(false);
isRequestInFlightRef.current = true
setIsSyncing(true)
setSyncError(false)
try {
if (quantity === 0) {
await removeFromCartMutation.mutateAsync(product.id);
toast.success(t("removed_from_cart"));
log("🗑️ Removing from cart")
await removeFromCartMutation.mutateAsync(product.id)
toast.success(t("removed_from_cart"))
} else if (isInCart) {
log("🔄 Updating cart quantity")
await updateCartMutation.mutateAsync({
productId: product.id,
quantity: quantity,
});
})
} else {
log(" Adding to cart")
await addToCartMutation.mutateAsync({
productId: product.id,
quantity: quantity,
});
})
}
isRequestInFlightRef.current = false;
setIsSyncing(false);
retryCountRef.current = 0;
clearPendingUpdate();
await refetchCart();
log("✅ Sync successful")
await refetchCart()
retryCountRef.current = 0
clearPendingUpdate()
if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current;
pendingQuantityRef.current = null;
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100);
const nextQuantity = pendingQuantityRef.current
pendingQuantityRef.current = null
log("📤 Processing queued update:", nextQuantity)
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100)
}
} catch (error) {
console.error("Sync failed:", error);
isRequestInFlightRef.current = false;
log("Sync failed:", error)
setLocalQuantity(cartItem?.product_quantity || 1)
toast.error("Failed to update quantity", {
description: "Please try again",
})
if (retryCountRef.current >= 3) {
setLocalQuantity(cartItem?.product_quantity || 1);
clearPendingUpdate();
}
retrySyncRef.current?.(quantity);
retrySyncRef.current?.(quantity)
} finally {
isRequestInFlightRef.current = false
setIsSyncing(false)
}
},
[
@@ -229,127 +285,122 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
addToCartMutation,
removeFromCartMutation,
cartItem,
clearPendingUpdate,
refetchCart,
clearPendingUpdate,
t,
]
);
],
)
syncToServerRef.current = syncToServer;
syncToServerRef.current = syncToServer
useEffect(() => {
if (!product?.id) return;
if (!isInCart || !product?.id) return
const loadPendingUpdates = () => {
try {
const stored = sessionStorage.getItem(PENDING_PRODUCT_UPDATES_KEY);
if (stored) {
const pending: Record<number, PendingUpdate> = JSON.parse(stored);
const productPending = pending[product.id];
if (
productPending &&
productPending.quantity !== (cartItem?.product_quantity || 1)
) {
setLocalQuantity(productPending.quantity);
pendingQuantityRef.current = productPending.quantity;
retryCountRef.current = productPending.retryCount;
setTimeout(
() => syncToServerRef.current?.(productPending.quantity),
500
);
// If local matches server, nothing to sync
if (localQuantity === (cartItem?.product_quantity || 1)) {
return
}
}
} catch (error) {
console.error("Failed to load pending updates:", error);
}
};
loadPendingUpdates();
}, [product?.id, cartItem]);
useEffect(() => {
if (!isInCart || !product?.id) return;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
clearTimeout(debounceTimerRef.current)
}
if (localQuantity === (cartItem?.product_quantity || 1)) {
return;
}
savePendingUpdate(localQuantity);
debounceTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(localQuantity);
}, 800);
syncToServerRef.current?.(localQuantity)
}, 800)
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
clearTimeout(debounceTimerRef.current)
}
};
}, [localQuantity, isInCart, product?.id, cartItem, savePendingUpdate]);
}
}, [localQuantity, isInCart, product?.id, cartItem?.product_quantity])
// Cleanup
useEffect(() => {
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
};
}, []);
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
if (retryTimerRef.current) clearTimeout(retryTimerRef.current)
}
}, [])
const handleAddToCart = useCallback(async () => {
if (!product?.id) return;
if (!product?.id) return
setIsSyncing(true);
if (localQuantity > availableStock) {
setShowStockModal(true)
return
}
setIsSyncing(true)
shouldSyncFromCartRef.current = false
try {
await addToCartMutation.mutateAsync({
productId: product.id,
quantity: localQuantity,
});
})
await refetchCart();
setIsSyncing(false);
lastSyncedQuantityRef.current = localQuantity
setTimeout(() => {
shouldSyncFromCartRef.current = true
refetchCart()
}, 150)
setIsSyncing(false)
toast.success(t("added_to_cart"), {
description: `${product.name} ${t("added_to_cart_description")}`,
});
})
} catch (error) {
console.error("Add to cart error:", error);
setIsSyncing(false);
console.error("Add to cart error:", error)
setIsSyncing(false)
shouldSyncFromCartRef.current = true
toast.error(t("error"), {
description: t("add_to_cart_failed"),
});
})
}
}, [product, localQuantity, addToCartMutation, refetchCart, t]);
}, [product, localQuantity, availableStock, addToCartMutation, refetchCart, t])
const handleQuantityIncrease = useCallback(() => {
log(" Quantity increase clicked:", {
current: localQuantity,
availableStock,
})
if (localQuantity >= availableStock) {
setShowStockModal(true);
return;
log("⚠️ Stock limit reached")
setShowStockModal(true)
return
}
setLocalQuantity((prev) => prev + 1);
}, [localQuantity, availableStock]);
setLocalQuantity((prev) => {
const newVal = prev + 1
log("📈 New local quantity:", newVal)
return newVal
})
}, [localQuantity, availableStock])
const handleQuantityDecrease = useCallback(() => {
if (localQuantity <= 0) return;
setLocalQuantity((prev) => prev - 1);
}, [localQuantity]);
log(" Quantity decrease clicked:", { current: localQuantity })
if (localQuantity <= 0) return
setLocalQuantity((prev) => {
const newVal = prev - 1
log("📉 New local quantity:", newVal)
return newVal
})
}, [localQuantity])
const handleToggleFavorite = useCallback(() => {
setIsFavorite(!isFavorite);
}, [isFavorite]);
setIsFavorite(!isFavorite)
}, [isFavorite])
const handleSubmitReview = useCallback(
async (rating: number, text: string) => {
if (!product?.id || rating === 0 || !text.trim()) {
toast.error(t("error"), {
description: "Please provide rating and review text",
});
return;
})
return
}
try {
@@ -358,25 +409,24 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
rating: rating,
title: text,
source: "site",
});
})
// ✅ Refetch product to get updated reviews
await refetchProduct();
await refetchProduct()
toast.success("Review submitted successfully!");
setShowReviewModal(false);
toast.success("Review submitted successfully!")
setShowReviewModal(false)
} catch (error) {
toast.error(t("error"), {
description: "Failed to submit review",
});
})
}
},
[product?.id, submitReviewMutation, refetchProduct, t]
);
[product?.id, submitReviewMutation, refetchProduct, t],
)
const loadingSkeleton = useMemo(
() => (
<div className="container mx-auto px-4 py-8">
<div className=" mx-auto px-4 py-8">
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 max-w-2xl">
<Skeleton className="aspect-square w-full rounded-2xl" />
@@ -393,33 +443,25 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
</div>
</div>
),
[]
);
[],
)
if (productLoading) return loadingSkeleton;
if (productLoading) return loadingSkeleton
if (error || !product) {
return (
<div className="container mx-auto px-4 py-8 text-center">
<h2 className="text-2xl font-bold text-red-600">
{t("product_not_found")}
</h2>
<p className="text-gray-500 mt-2">
{t("product_not_found_description")}
</p>
<div className=" mx-auto px-4 py-8 text-center">
<h2 className="text-2xl font-bold text-red-600">{t("product_not_found")}</h2>
<p className="text-gray-500 mt-2">{t("product_not_found_description")}</p>
</div>
);
)
}
return (
<>
<div className="px-2 md:px-4 lg:px-6 rounded-lg mb-18 space-y-8 max-w-[1504px] mx-auto">
<div className="flex flex-col lg:flex-row gap-8 rounded-b-lg bg-white p-4">
<ProductImageGallery
images={imageUrls}
productName={product.name}
noImageText={t("no_image")}
/>
<ProductImageGallery images={imageUrls} productName={product.name} noImageText={t("no_image")} />
<ProductInfoCard
brandName={product.brand?.name}
@@ -477,5 +519,5 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
isSubmitting={submitReviewMutation.isPending}
/>
</>
);
)
}

View File

@@ -94,13 +94,9 @@ export function ProductPurchaseCard({
variant="outline"
size="icon"
onClick={onQuantityIncrease}
disabled={localQuantity >= availableStock || isSyncing}
disabled={isSyncing}
className={`rounded-lg h-12 w-12 ${
isSyncing ? "opacity-70" : ""
} ${
localQuantity >= availableStock
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<Plus className="h-5 w-5" />

View File

@@ -51,9 +51,9 @@ export function ProductReviewsSection({
<h3 className="text-2xl font-bold">{t("customer_reviews")}</h3>
<div className="flex items-center gap-2 mt-2">
{renderStars(Math.round(averageRating))}
<span className="text-sm text-gray-600">
{/* <span className="text-sm text-gray-600">
{averageRating.toFixed(1)} out of 5
</span>
</span> */}
</div>
</div>
<Button onClick={onWriteReview} className="rounded-lg bg-[#005bff] hover:bg-[#0041c4]">

View File

@@ -14,7 +14,8 @@ import {
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useUserProfile, useUpdateProfile } from "@/lib/hooks";
import { clearAuthToken } from "@/lib/api";
import { useLogout } from "@/lib/hooks/useAuth";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
@@ -46,9 +47,9 @@ export default function ClientProfilePage(props: ProfilePageProps) {
});
}
}, [user, isEditing]);
const { mutate: logout, isPending: isLoggingOut } = useLogout();
const handleLogout = useCallback(() => {
clearAuthToken();
logout();
window.location.href = "/";
}, []);
@@ -117,7 +118,7 @@ export default function ClientProfilePage(props: ProfilePageProps) {
const loadingSkeleton = useMemo(
() => (
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8 pt-20 sm:pt-24">
<div className="container mx-auto max-w-4xl">
<div className=" mx-auto max-w-4xl">
<div className="mb-6 sm:mb-8">
<Skeleton className="h-8 sm:h-10 w-32 sm:w-40 mb-2" />
<Skeleton className="h-4 w-48 sm:w-64" />
@@ -171,12 +172,12 @@ export default function ClientProfilePage(props: ProfilePageProps) {
return (
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8 pb-20 sm:pb-24">
<div className="container mx-auto max-w-4xl">
<div className=" mx-auto max-w-4xl">
{/* Header Section */}
<div className="mb-6 sm:mb-8">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-gray-900 mb-1 sm:mb-2 truncate">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold text-gray-900 mb-1 sm:mb-2 truncate">
{t("profile")}
</h1>
<p className="text-sm sm:text-base text-gray-600">
@@ -271,8 +272,7 @@ export default function ClientProfilePage(props: ProfilePageProps) {
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-5">
{/* Phone Field */}
<div className="space-y-2">
<Label
@@ -321,8 +321,7 @@ export default function ClientProfilePage(props: ProfilePageProps) {
placeholder={t("enter_address")}
/>
</div>
</div>
</div>
{/* Action Buttons - Edit Mode */}
{isEditing && (

View File

@@ -1,19 +0,0 @@
import * as React from 'react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile
}

View File

@@ -1,191 +0,0 @@
'use client'
// Inspired by react-hot-toast library
import * as React from 'react'
import type { ToastActionElement, ToastProps } from '@/components/ui/sonner'
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType['ADD_TOAST']
toast: ToasterToast
}
| {
type: ActionType['UPDATE_TOAST']
toast: Partial<ToasterToast>
}
| {
type: ActionType['DISMISS_TOAST']
toastId?: ToasterToast['id']
}
| {
type: ActionType['REMOVE_TOAST']
toastId?: ToasterToast['id']
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
}
case 'DISMISS_TOAST': {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
}
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, 'id'>
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
}
}
export { useToast, toast }

View File

@@ -179,5 +179,8 @@
"favorites_empty": "У вас пока нет избранных товаров",
"favorites_empty_message": "Добавьте любимые товары в избранное",
"orders_empty": "У вас пока нет заказов",
"orders_empty_message": "Начните делать заказы"
"orders_empty_message": "Начните делать заказы",
"product": "Продукт",
"collection_not_found": "Коллекция не найдена"
}

View File

@@ -179,5 +179,7 @@
"favorites_empty": "Siziň saýlanan harytlaryňyz ýok",
"favorites_empty_message": "Halan harydyňyz saýlap goýuň!",
"orders_empty": "Siziň sargytlaryňyz ýok",
"orders_empty_message": "Sargyt etmäge başlaň!"
"orders_empty_message": "Sargyt etmäge başlaň!",
"product": "haryt",
"collection_not_found": "Kolleksiýa tapylmady"
}

View File

@@ -1,61 +1,24 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios"
// lib/api.ts
/**
* 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
}
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios";
import TokenStorage from "./tokenStorage";
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 => {
const authToken = getTokenFromCookie("authToken")
if (authToken) return authToken
const guestToken = getTokenFromCookie("guestToken")
if (guestToken) return guestToken
return null
}
/**
* Map internal locale codes to API language codes
*/
const localeToApiLang = (locale: string): string => {
const mapping: Record<string, string> = {
tm: "tk",
ru: "ru",
}
return mapping[locale] || locale
}
const mapping: Record<string, string> = { tm: "tk", ru: "ru" };
return mapping[locale] || locale;
};
/**
* Centralized API client with interceptors
*/
class APIClient {
private client: AxiosInstance
private baseUrl: string
private isRefreshing = false
private client: AxiosInstance;
private baseUrl: string;
private isRefreshing = false;
private failedQueue: Array<{
resolve: (value?: unknown) => void
reject: (reason?: unknown) => void
}> = []
resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void;
}> = [];
constructor() {
this.baseUrl = process.env.NEXT_PUBLIC_API_URL || "https://api.example.com"
this.baseUrl = process.env.NEXT_PUBLIC_API_URL || "https://api.example.com";
this.client = axios.create({
baseURL: `${this.baseUrl}/api/v1`,
@@ -64,64 +27,60 @@ class APIClient {
"Content-Type": "application/json",
"Api-Token": process.env.NEXT_PUBLIC_API_TOKEN || "123",
},
})
});
this.setupInterceptors()
this.setupInterceptors();
}
private setupInterceptors(): void {
// Request interceptor
this.client.interceptors.request.use(
(config) => {
const token = getToken()
const token = TokenStorage.getActiveToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`
config.headers.Authorization = `Bearer ${token}`;
}
// Add language parameter
let lang = "tk" // default fallback
let lang = "tk";
if (typeof window !== "undefined") {
// Try to get from i18n
if ((window as any).i18n?.language) {
lang = localeToApiLang((window as any).i18n.language)
}
// Try to get from pathname as fallback
else {
const pathLocale = window.location.pathname.split("/")[1]
lang = localeToApiLang((window as any).i18n.language);
} else {
const pathLocale = window.location.pathname.split("/")[1];
if (pathLocale === "tm" || pathLocale === "ru") {
lang = localeToApiLang(pathLocale)
lang = localeToApiLang(pathLocale);
}
}
}
const url = config.url || ""
const separator = url.includes("?") ? "&" : "?"
config.url = `${url}${separator}lang=${lang}`
const url = config.url || "";
const separator = url.includes("?") ? "&" : "?";
config.url = `${url}${separator}lang=${lang}`;
return config
return config;
},
(error) => Promise.reject(error)
)
);
// Response interceptor
this.client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config
const originalRequest = error.config;
// Handle 401 errors
if (error.response?.status === 401 && !originalRequest._retry) {
if (this.isRefreshing) {
return new Promise((resolve, reject) => {
this.failedQueue.push({ resolve, reject })
this.failedQueue.push({ resolve, reject });
})
.then(() => this.client(originalRequest))
.catch((err) => Promise.reject(err))
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true
this.isRefreshing = true
originalRequest._retry = true;
this.isRefreshing = true;
try {
const guestTokenResponse = await axios.post(
@@ -133,30 +92,29 @@ class APIClient {
"Api-Token": process.env.NEXT_PUBLIC_API_TOKEN || "123",
},
}
)
);
const newToken = guestTokenResponse.data?.token || guestTokenResponse.data?.data
const newToken = guestTokenResponse.data?.token || guestTokenResponse.data?.data;
if (newToken) {
setTokenInCookie("guestToken", newToken)
this.processQueue(null)
return this.client(originalRequest)
TokenStorage.setGuestToken(newToken);
this.processQueue(null);
return this.client(originalRequest);
}
} catch (refreshError) {
this.processQueue(refreshError)
this.clearAuthToken()
this.processQueue(refreshError);
TokenStorage.clearTokens();
if (typeof window !== "undefined") {
window.location.href = "/login"
window.location.href = "/login";
}
return Promise.reject(refreshError)
return Promise.reject(refreshError);
} finally {
this.isRefreshing = false
this.isRefreshing = false;
}
}
// Handle HTML error responses
if (
error.response?.data &&
typeof error.response.data === "string" &&
@@ -168,64 +126,44 @@ class APIClient {
...error.response,
data: { message: "Server returned HTML instead of JSON" },
},
})
});
}
return Promise.reject(error)
return Promise.reject(error);
}
)
);
}
private processQueue(error: any): void {
this.failedQueue.forEach((promise) => {
if (error) {
promise.reject(error)
promise.reject(error);
} else {
promise.resolve()
promise.resolve();
}
})
this.failedQueue = []
});
this.failedQueue = [];
}
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.get<T>(url, config)
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)
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)
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)
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"]
return this.client.delete<T>(url, config);
}
}
export const apiClient = new APIClient()
export const setAuthToken = (token: string) => apiClient.setAuthToken(token)
export const setGuestToken = (token: string) => apiClient.setGuestToken(token)
export const clearAuthToken = () => apiClient.clearAuthToken()
export const apiClient = new APIClient();

View File

@@ -1,23 +1,25 @@
export * from "../../features/products/hooks/useProducts"
export * from "../../features/category/hooks/useCategories"
export * from "../../features/cart/hooks/useCart"
export * from "../../features/favorites/hooks/useFavorites"
export * from "../../features/orders/hooks/useOrders"
export * from "../../features/search/hooks/useSearch"
export * from "../../features/profile/hooks/useUserProfile"
export * from "../../features/openStore/hooks/useOpenStore"
export * from "../../features/products/hooks/useProducts";
export * from "../../features/category/hooks/useCategories";
export * from "../../features/cart/hooks/useCart";
export * from "../../features/favorites/hooks/useFavorites";
export * from "../../features/orders/hooks/useOrders";
export * from "../../features/search/hooks/useSearch";
export * from "../../features/profile/hooks/useUserProfile";
export * from "../../features/openStore/hooks/useOpenStore";
export * from "../../features/cart/hooks/useAddresses"
export * from "../../features/cart/hooks/usePaymentTypes"
export * from "../../features/cart/hooks/useAddresses";
export * from "../../features/cart/hooks/usePaymentTypes";
export * from "../../features/home/hooks/useMedia"
export * from "../../features/home/hooks/useCollections"
export * from "../../features/home/hooks/useMedia";
export * from "../../features/home/hooks/useCollections";
// Export types
export type { Product, Category, Cart, CartItem, Order, Favorite, Banner } from "@/lib/types/api"
export type {
Product,
Category,
Cart,
CartItem,
Order,
Favorite,
Banner,
} from "@/lib/types/api";

View File

@@ -1,6 +1,10 @@
// lib/hooks/useAuth.ts
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react";
import { apiClient, setAuthToken, clearAuthToken, setGuestToken } from "@/lib/api";
import { apiClient } from "@/lib/api";
import TokenStorage from "@/lib/tokenStorage";
import { AxiosError } from "axios";
// ==================== TYPES ====================
interface LoginCredentials {
@@ -30,59 +34,131 @@ interface AuthResponse {
};
}
// ==================== AUTH STATUS ====================
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;
};
interface AuthError {
message: string;
code?: string;
statusCode?: number;
}
// ==================== UTILITIES ====================
function extractToken(data: AuthResponse): string {
// Enforce consistent token extraction
const token = data.token || data.data;
if (!token) {
throw new Error("No token received from server");
}
return token;
}
function handleAuthError(error: unknown): AuthError {
if (error instanceof AxiosError) {
if (error.code === 'ECONNABORTED') {
return {
message: "Request timeout - server not responding",
code: "TIMEOUT",
statusCode: 408
};
}
if (error.response) {
return {
message: error.response.data?.message || "Authentication failed",
code: error.response.data?.code || "AUTH_ERROR",
statusCode: error.response.status
};
}
if (error.request) {
return {
message: "Network error - cannot reach server",
code: "NETWORK_ERROR",
statusCode: 0
};
}
}
return {
message: error instanceof Error ? error.message : "Unknown error occurred",
code: "UNKNOWN_ERROR"
};
}
// ==================== AUTH STATUS ====================
export function useAuthStatus() {
const [isLoading, setIsLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
useEffect(() => {
const authToken = getTokenFromCookie("authToken");
setIsAuthenticated(!!authToken);
setIsAuthenticated(TokenStorage.hasAuthToken());
setIsLoading(false);
}, []);
return {
isAuthenticated,
isLoading,
};
return { isAuthenticated, isLoading };
}
// ==================== GUEST TOKEN ====================
export function useGetGuestToken() {
return useMutation({
mutationFn: async (): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>("/auth/guest-token", {});
return response.data;
},
onSuccess: (data) => {
const token = data?.token || data?.data;
if (token) {
setGuestToken(token);
mutationFn: async (): Promise<string> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
try {
const response = await apiClient.post<AuthResponse>(
"/auth/guest-token",
{},
{
signal: controller.signal,
timeout: 10000
}
);
clearTimeout(timeoutId);
return extractToken(response.data);
} catch (error) {
clearTimeout(timeoutId);
throw handleAuthError(error);
}
},
onError: (error) => {
console.error("Guest token hatası:", error);
onSuccess: (token) => {
TokenStorage.setGuestToken(token);
},
onError: (error: AuthError) => {
console.error("[Guest Token] Failed:", {
message: error.message,
code: error.code,
statusCode: error.statusCode
});
},
retry: (failureCount, error) => {
const authError = error as AuthError;
// Retry on network errors, not on auth errors
if (authError.code === "NETWORK_ERROR" || authError.code === "TIMEOUT") {
return failureCount < 2;
}
return false;
},
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
});
}
// ==================== LOGIN ====================
export function useLogin() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (credentials: LoginCredentials): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>("/auth/login", credentials);
return response.data;
mutationFn: async (credentials: LoginCredentials): Promise<string> => {
const response = await apiClient.post<AuthResponse>(
"/auth/login",
credentials,
{ timeout: 15000 }
);
return extractToken(response.data);
},
onSuccess: (token) => {
TokenStorage.setAuthToken(token);
queryClient.invalidateQueries({ queryKey: ["auth-status"] });
queryClient.invalidateQueries({ queryKey: ["user-profile"] });
},
onError: (error) => {
console.error("Login hatası:", error);
const authError = handleAuthError(error);
console.error("[Login] Failed:", authError);
},
});
}
@@ -92,19 +168,22 @@ export function useRegister() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (userData: RegisterData): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>("/auth/register", userData);
return response.data;
mutationFn: async (userData: RegisterData): Promise<string> => {
const response = await apiClient.post<AuthResponse>(
"/auth/register",
userData,
{ timeout: 15000 }
);
return extractToken(response.data);
},
onSuccess: (data) => {
const token = data?.token || data?.data;
if (token) {
setAuthToken(token);
onSuccess: (token) => {
TokenStorage.setAuthToken(token);
queryClient.invalidateQueries({ queryKey: ["auth-status"] });
}
queryClient.invalidateQueries({ queryKey: ["user-profile"] });
},
onError: (error) => {
console.error("Register hatası:", error);
const authError = handleAuthError(error);
console.error("[Register] Failed:", authError);
},
});
}
@@ -114,19 +193,22 @@ export function useVerifyToken() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (verifyData: VerifyTokenData): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>("/auth/verify", verifyData);
return response.data;
mutationFn: async (verifyData: VerifyTokenData): Promise<string> => {
const response = await apiClient.post<AuthResponse>(
"/auth/verify",
verifyData,
{ timeout: 15000 }
);
return extractToken(response.data);
},
onSuccess: (data) => {
const token = data?.data || data?.token;
if (token) {
setAuthToken(token);
onSuccess: (token) => {
TokenStorage.setAuthToken(token);
queryClient.invalidateQueries({ queryKey: ["auth-status"] });
}
queryClient.invalidateQueries({ queryKey: ["user-profile"] });
},
onError: (error) => {
console.error("Verify hatası:", error);
const authError = handleAuthError(error);
console.error("[Verify] Failed:", authError);
},
});
}
@@ -138,23 +220,28 @@ export function useLogout() {
return useMutation({
mutationFn: async (): Promise<void> => {
try {
await apiClient.post("/auth/logout");
await apiClient.post("/auth/logout", {}, { timeout: 5000 });
} catch (error) {
console.warn("Logout endpoint çalışmadı:", error);
// Logout should succeed even if server call fails
console.warn("[Logout] Server call failed, clearing local state anyway");
}
},
onSuccess: () => {
clearAuthToken();
TokenStorage.clearTokens();
queryClient.clear();
if (typeof window !== "undefined") {
window.location.href = "/login";
window.location.href = "/";
}
},
onError: (error) => {
console.error("Logout hatası:", error);
clearAuthToken();
onError: () => {
// Always clear local state on logout
TokenStorage.clearTokens();
queryClient.clear();
if (typeof window !== "undefined") {
window.location.href = "/";
}
},
});
}

View File

@@ -1,46 +0,0 @@
/**
* 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)
}

57
lib/tokenStorage.ts Normal file
View File

@@ -0,0 +1,57 @@
// lib/services/tokenStorage.ts
/**
* Centralized token storage using localStorage only
* Single source of truth for all token operations
*/
const AUTH_TOKEN_KEY = "authToken";
const GUEST_TOKEN_KEY = "guestToken";
class TokenStorage {
private static isClient = typeof window !== "undefined";
static getAuthToken(): string | null {
if (!this.isClient) return null;
return localStorage.getItem(AUTH_TOKEN_KEY);
}
static getGuestToken(): string | null {
if (!this.isClient) return null;
return localStorage.getItem(GUEST_TOKEN_KEY);
}
static getActiveToken(): string | null {
return this.getAuthToken() || this.getGuestToken();
}
static setAuthToken(token: string): void {
if (!this.isClient) return;
localStorage.setItem(AUTH_TOKEN_KEY, token);
localStorage.removeItem(GUEST_TOKEN_KEY); // Auth token replaces guest token
}
static setGuestToken(token: string): void {
if (!this.isClient) return;
// Only set guest token if no auth token exists
if (!this.getAuthToken()) {
localStorage.setItem(GUEST_TOKEN_KEY, token);
}
}
static clearTokens(): void {
if (!this.isClient) return;
localStorage.removeItem(AUTH_TOKEN_KEY);
localStorage.removeItem(GUEST_TOKEN_KEY);
}
static hasAuthToken(): boolean {
return !!this.getAuthToken();
}
static hasAnyToken(): boolean {
return !!this.getActiveToken();
}
}
export default TokenStorage;

View File

@@ -1,76 +0,0 @@
/**
* 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
}

View File

@@ -1,21 +0,0 @@
/**
* 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

Binary file not shown.

View File

@@ -11,8 +11,9 @@ const nextConfig: NextConfig = {
unoptimized: true,
remotePatterns: [
{
protocol: "https",
protocol: "http",
hostname: "shop.post.tm",
port: "8080",
},
],
},

80
package-lock.json generated
View File

@@ -26,7 +26,7 @@
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.548.0",
"next": "16.0.1",
"next": "^16.0.10",
"next-intl": "^4.5.0",
"next-themes": "^0.4.6",
"react": "19.2.0",
@@ -1148,9 +1148,9 @@
}
},
"node_modules/@next/env": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.1.tgz",
"integrity": "sha512-LFvlK0TG2L3fEOX77OC35KowL8D7DlFF45C0OvKMC4hy8c/md1RC4UMNDlUGJqfCoCS2VWrZ4dSE6OjaX5+8mw==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -1164,9 +1164,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.1.tgz",
"integrity": "sha512-R0YxRp6/4W7yG1nKbfu41bp3d96a0EalonQXiMe+1H9GTHfKxGNCGFNWUho18avRBPsO8T3RmdWuzmfurlQPbg==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.0.10.tgz",
"integrity": "sha512-4XgdKtdVsaflErz+B5XeG0T5PeXKDdruDf3CRpnhN+8UebNa5N2H58+3GDgpn/9GBurrQ1uWW768FfscwYkJRg==",
"cpu": [
"arm64"
],
@@ -1180,9 +1180,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.1.tgz",
"integrity": "sha512-kETZBocRux3xITiZtOtVoVvXyQLB7VBxN7L6EPqgI5paZiUlnsgYv4q8diTNYeHmF9EiehydOBo20lTttCbHAg==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.0.10.tgz",
"integrity": "sha512-spbEObMvRKkQ3CkYVOME+ocPDFo5UqHb8EMTS78/0mQ+O1nqE8toHJVioZo4TvebATxgA8XMTHHrScPrn68OGw==",
"cpu": [
"x64"
],
@@ -1196,9 +1196,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.1.tgz",
"integrity": "sha512-hWg3BtsxQuSKhfe0LunJoqxjO4NEpBmKkE+P2Sroos7yB//OOX3jD5ISP2wv8QdUwtRehMdwYz6VB50mY6hqAg==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.0.10.tgz",
"integrity": "sha512-uQtWE3X0iGB8apTIskOMi2w/MKONrPOUCi5yLO+v3O8Mb5c7K4Q5KD1jvTpTF5gJKa3VH/ijKjKUq9O9UhwOYw==",
"cpu": [
"arm64"
],
@@ -1212,9 +1212,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.1.tgz",
"integrity": "sha512-UPnOvYg+fjAhP3b1iQStcYPWeBFRLrugEyK/lDKGk7kLNua8t5/DvDbAEFotfV1YfcOY6bru76qN9qnjLoyHCQ==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.0.10.tgz",
"integrity": "sha512-llA+hiDTrYvyWI21Z0L1GiXwjQaanPVQQwru5peOgtooeJ8qx3tlqRV2P7uH2pKQaUfHxI/WVarvI5oYgGxaTw==",
"cpu": [
"arm64"
],
@@ -1228,9 +1228,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.1.tgz",
"integrity": "sha512-Et81SdWkcRqAJziIgFtsFyJizHoWne4fzJkvjd6V4wEkWTB4MX6J0uByUb0peiJQ4WeAt6GGmMszE5KrXK6WKg==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.0.10.tgz",
"integrity": "sha512-AK2q5H0+a9nsXbeZ3FZdMtbtu9jxW4R/NgzZ6+lrTm3d6Zb7jYrWcgjcpM1k8uuqlSy4xIyPR2YiuUr+wXsavA==",
"cpu": [
"x64"
],
@@ -1244,9 +1244,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.1.tgz",
"integrity": "sha512-qBbgYEBRrC1egcG03FZaVfVxrJm8wBl7vr8UFKplnxNRprctdP26xEv9nJ07Ggq4y1adwa0nz2mz83CELY7N6Q==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.0.10.tgz",
"integrity": "sha512-1TDG9PDKivNw5550S111gsO4RGennLVl9cipPhtkXIFVwo31YZ73nEbLjNC8qG3SgTz/QZyYyaFYMeY4BKZR/g==",
"cpu": [
"x64"
],
@@ -1260,9 +1260,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.1.tgz",
"integrity": "sha512-cPuBjYP6I699/RdbHJonb3BiRNEDm5CKEBuJ6SD8k3oLam2fDRMKAvmrli4QMDgT2ixyRJ0+DTkiODbIQhRkeQ==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.0.10.tgz",
"integrity": "sha512-aEZIS4Hh32xdJQbHz121pyuVZniSNoqDVx1yIr2hy+ZwJGipeqnMZBJHyMxv2tiuAXGx6/xpTcQJ6btIiBjgmg==",
"cpu": [
"arm64"
],
@@ -1276,9 +1276,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.1.tgz",
"integrity": "sha512-XeEUJsE4JYtfrXe/LaJn3z1pD19fK0Q6Er8Qoufi+HqvdO4LEPyCxLUt4rxA+4RfYo6S9gMlmzCMU2F+AatFqQ==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.0.10.tgz",
"integrity": "sha512-E+njfCoFLb01RAFEnGZn6ERoOqhK1Gl3Lfz1Kjnj0Ulfu7oJbuMyvBKNj/bw8XZnenHDASlygTjZICQW+rYW1Q==",
"cpu": [
"x64"
],
@@ -6587,12 +6587,12 @@
}
},
"node_modules/next": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.1.tgz",
"integrity": "sha512-e9RLSssZwd35p7/vOa+hoDFggUZIUbZhIUSLZuETCwrCVvxOs87NamoUzT+vbcNAL8Ld9GobBnWOA6SbV/arOw==",
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/next/-/next-16.0.10.tgz",
"integrity": "sha512-RtWh5PUgI+vxlV3HdR+IfWA1UUHu0+Ram/JBO4vWB54cVPentCD0e+lxyAYEsDTqGGMg7qpjhKh6dc6aW7W/sA==",
"license": "MIT",
"dependencies": {
"@next/env": "16.0.1",
"@next/env": "16.0.10",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -6605,14 +6605,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.0.1",
"@next/swc-darwin-x64": "16.0.1",
"@next/swc-linux-arm64-gnu": "16.0.1",
"@next/swc-linux-arm64-musl": "16.0.1",
"@next/swc-linux-x64-gnu": "16.0.1",
"@next/swc-linux-x64-musl": "16.0.1",
"@next/swc-win32-arm64-msvc": "16.0.1",
"@next/swc-win32-x64-msvc": "16.0.1",
"@next/swc-darwin-arm64": "16.0.10",
"@next/swc-darwin-x64": "16.0.10",
"@next/swc-linux-arm64-gnu": "16.0.10",
"@next/swc-linux-arm64-musl": "16.0.10",
"@next/swc-linux-x64-gnu": "16.0.10",
"@next/swc-linux-x64-musl": "16.0.10",
"@next/swc-win32-arm64-msvc": "16.0.10",
"@next/swc-win32-x64-msvc": "16.0.10",
"sharp": "^0.34.4"
},
"peerDependencies": {

View File

@@ -27,7 +27,7 @@
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.548.0",
"next": "16.0.1",
"next": "^16.0.10",
"next-intl": "^4.5.0",
"next-themes": "^0.4.6",
"react": "19.2.0",