Compare commits

...

10 Commits

Author SHA1 Message Date
Jelaletdin12
2cd3c84153 removed stock 2026-02-04 10:29:41 +05:00
@jcarymuhammedow
188df98bbf fixed fav prod on prod detail page 2026-01-14 17:00:05 +05:00
@jcarymuhammedow
071b45b98a fixed some errors 2026-01-08 18:01:17 +05:00
Jelaletdin12
7538bdb813 refactored some code 2025-12-24 22:04:44 +05:00
Jelaletdin12
342fb31906 fixed dehydrate 2025-12-24 16:05:17 +05:00
Jelaletdin12
d3ed4d1901 upgraded cart add function 2025-12-24 15:43:27 +05:00
Jelaletdin12
2b46d525f2 fixed some bugs 2025-12-23 13:32:57 +05:00
Jelaletdin12
cdc9fa686f cleaned code from logs and some comments 2025-12-19 18:14:29 +05:00
Jelaletdin12
0fb4e2765c fixed some bugs 2025-12-18 23:19:45 +05:00
Jelaletdin12
6d0064b106 added empty pages 2025-12-15 17:55:34 +05:00
83 changed files with 4684 additions and 2734 deletions

View File

@@ -2,10 +2,10 @@
2. Harytlar kem kas bolanu ucin home page doly gorkezenok
3. Filter nahili isleyar
4. Order nadip otmen etmeli.
5. Review feed back yazylyan yer bamy bolmalymy
6. Open Store api field ler nahili bolmaly.
7. Delivery type soramaly, type lar yok

BIN
api.zip

Binary file not shown.

View File

@@ -3,17 +3,20 @@ import { useState, useEffect, useMemo } from "react";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import CartItemCard from "../../../features/cart/components/CartItemCard";
import CartItemSkeleton from "../../../features/cart/components/CartItemSkeleton";
import OrderSummary from "../../../features/cart/components/OrderSummary";
import OrderSummarySkeleton from "../../../features/cart/components/OrderSummarySkeleton";
import {
useCart,
useCreateOrder,
useRegions,
usePaymentTypes,
} from "@/lib/hooks";
import { userStore } from "@/features/profile/userStore";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import type { DeliveryType, PaymentType } from "@/lib/types/api";
import EmptyCart from "@/features/cart/components/EmptyCart";
import ErrorPage from "@/components/ErrorPage";
export default function CartPage() {
const [isClient, setIsClient] = useState(false);
@@ -23,29 +26,23 @@ export default function CartPage() {
const [selectedRegion, setSelectedRegion] = useState<string>("");
const [selectedProvince, setSelectedProvince] = useState<number | null>(null);
const [note, setNote] = useState<string>("");
const [phone, setPhone] = useState<string>("");
const [phone, setPhone] = useState<string>("+993 ");
const [name, setName] = useState<string>("");
const [lastName, setLastName] = useState<string>("");
const router = useRouter();
const t = useTranslations();
const { data: cartResponse, isLoading, isError } = useCart();
const { data: provinces = [] } = useRegions();
const { data: paymentTypes = [] } = usePaymentTypes();
const { data: cartResponse, isLoading: cartLoading, isError } = useCart();
const { data: provinces = [], isLoading: provincesLoading } = useRegions();
const { data: paymentTypes = [], isLoading: paymentTypesLoading } =
usePaymentTypes();
const { mutate: createOrder, isPending: isCreatingOrder } = useCreateOrder();
const cartItems = cartResponse?.data || [];
const isLoading = cartLoading || provincesLoading || paymentTypesLoading;
useEffect(() => {
setIsClient(true);
// Get user data from store if available
const orderData = userStore.getOrderData();
if (orderData) {
if (orderData.customer_name) setName(orderData.customer_name);
if (orderData.customer_last_name) setLastName(orderData.customer_last_name);
if (orderData.customer_phone) setPhone(orderData.customer_phone);
}
}, []);
const regionGroups = useMemo(() => {
@@ -91,69 +88,86 @@ export default function CartPage() {
setSelectedProvince(null);
};
const handleCompleteOrder = () => {
if (!selectedRegion || !selectedProvince || !paymentType || !phone || !name) {
console.warn("Missing required fields for order");
return;
}
const selectedProvinceData = provinces.find(
(p) => p.id === selectedProvince
);
if (!selectedProvinceData) return;
const orderData = userStore.getOrderData();
if (!orderData) {
console.error("User data not found");
router.push("/login");
return;
}
createOrder(
{
customer_name: name,
customer_phone: phone,
customer_address: selectedProvinceData.name,
shipping_method: deliveryType === "PICK_UP" ? "pickup" : "standart",
payment_type_id: paymentType.id,
region: selectedRegion,
note: note || undefined,
},
{
onSuccess: () => {
router.push(`/orders`);
},
}
);
const formatPhoneForBackend = (phoneNumber: string): string => {
return phoneNumber.replace(/^\+993\s*/, "").replace(/\s+/g, "");
};
const handleCompleteOrder = () => {
if (!selectedRegion || !selectedProvince || !paymentType || !phone || !name) {
console.warn("Missing required fields for order");
return;
}
const phoneDigits = formatPhoneForBackend(phone);
if (phoneDigits.length !== 8) {
console.warn("Phone number must be exactly 8 digits");
return;
}
const selectedProvinceData = provinces.find((p) => p.id === selectedProvince);
if (!selectedProvinceData) return;
createOrder(
{
customer_name: `${name} ${lastName}`.trim(),
customer_phone: parseInt(phoneDigits, 10),
customer_address: selectedProvinceData.name,
shipping_method: "standart",
payment_type_id: paymentType.id,
region: selectedRegion,
note: note || undefined,
},
{
onSuccess: () => {
router.push(`/orders`);
},
}
);
};
if (!isClient) return null;
if (isLoading) {
return (
<div className="container mx-auto px-4 min-h-[90vh] flex items-center justify-center">
<p>{t("common.loading")}</p>
<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-4 md:p-6 rounded-xl">
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<CartItemSkeleton key={i} />
))}
</div>
</Card>
</div>
<OrderSummarySkeleton />
</div>
</div>
);
}
if (isError || cartItems.length === 0) {
return (
<div className="container mx-auto px-4 min-h-[90vh] flex items-center justify-center">
<h2 className="text-3xl md:text-4xl lg:text-5xl text-gray-400 font-semibold">
{t("cart_empty")}
</h2>
</div>
);
if (isError ) {
return <ErrorPage />;
}
if (cartItems.length === 0) {
return <EmptyCart />;
}
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="flex flex-col mx-auto max-w-[1504px] 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

@@ -1,36 +1,52 @@
import type { Metadata } from "next"
import type { Metadata } from "next";
type Props = {
params: Promise<{ locale: string; slug: string }>
}
params: Promise<{ locale: string; slug: string }>;
};
export const revalidate = 600 // ISR: Revalidate every 10 minutes
export const revalidate = 600; // ISR: Revalidate every 10 minutes
const CATEGORY_META = {
tm: {
suffix: " | Post shop",
description: "Kategoriýa boýunça harytlary gözläň",
ogLocale: "tk_TM",
},
ru: {
suffix: " | Post shop",
description: "Просмотр товаров в данной категории",
ogLocale: "ru_RU",
},
} as const;
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params
const { locale, slug } = await params;
const meta =
CATEGORY_META[locale as keyof typeof CATEGORY_META] ?? CATEGORY_META.ru;
return {
title: `${slug.charAt(0).toUpperCase() + slug.slice(1)} | E-Commerce`,
description: `Browse ${slug} products in our store`,
title: `${slug}${meta.suffix}`,
description: meta.description,
openGraph: {
locale,
type: "website",
title: `${slug.charAt(0).toUpperCase() + slug.slice(1)} | E-Commerce`,
description: `Browse ${slug} products in our store`,
locale: meta.ogLocale,
title: `${slug}${meta.suffix}`,
description: meta.description,
},
}
};
}
export async function generateStaticParams() {
// Generate static params for popular categories
const categories = ["electronics", "clothing", "home-garden"]
return categories.map((slug) => ({ slug }))
const categories = ["electronics", "clothing", "home-garden"];
return categories.map((slug) => ({ slug }));
}
export default async function CategoryPage(props: Props) {
const params = await props.params
const { slug } = params
const params = await props.params;
const { slug } = params;
const CategoryPageClient = (await import("../../../../features/category/components/CategoryPageClient")).default
return <CategoryPageClient params={params} />
const CategoryPageClient = (
await import("../../../../features/category/components/CategoryPageClient")
).default;
return <CategoryPageClient params={params} />;
}

View File

@@ -1,38 +1,64 @@
import type { Metadata } from "next"
import type { Metadata } from "next";
type Props = {
params: Promise<{ locale: string; slug: string }>
params: Promise<{ locale: string; slug: string }>;
};
export const revalidate = 600; // ISR: 10 minutes
const META = {
tm: {
titleSuffix: " | Post shop",
description: (name: string) => `${name} kolleksiýasyndaky harytlary gözläň`,
ogLocale: "tk_TM",
},
ru: {
titleSuffix: " | Post shop",
description: (name: string) => `Просмотр товаров из коллекции «${name}»`,
ogLocale: "ru_RU",
},
} as const;
function formatSlug(slug: string) {
return slug
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
export const revalidate = 600 // ISR: Revalidate every 10 minutes
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params
const { locale, slug } = await params;
const meta = META[locale as keyof typeof META] ?? META.ru;
const collectionName = formatSlug(slug);
const title = `${collectionName}${meta.titleSuffix}`;
const description = meta.description(collectionName);
return {
title: `${slug.charAt(0).toUpperCase() + slug.slice(1)} | E-Commerce`,
description: `Browse ${slug} collection products in our store`,
title,
description,
openGraph: {
locale,
type: "website",
title: `${slug.charAt(0).toUpperCase() + slug.slice(1)} | E-Commerce`,
description: `Browse ${slug} collection products in our store`,
locale: meta.ogLocale,
title,
description,
},
}
};
}
export async function generateStaticParams() {
// Generate static params for popular collections
const collections = ["new-arrivals", "best-sellers", "featured"]
return collections.map((slug) => ({ slug }))
const collections = ["new-arrivals", "best-sellers", "featured"];
return collections.map((slug) => ({ slug }));
}
export default async function CollectionPage(props: Props) {
const params = await props.params
const params = await props.params;
const CollectionPageClient = (
await import("../../../../features/collections/components/CollectionPageClient")
).default
return <CollectionPageClient params={params} />
}
await import(
"../../../../features/collections/components/CollectionPageClient"
)
).default;
return <CollectionPageClient params={params} />;
}

View File

@@ -5,6 +5,9 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "next-intl";
import ProductCard from "@/features/home/components/ProductCard";
import type { Favorite } from "@/lib/types/api";
import EmptyFavorites from "@/features/favorites/components/EmptyFavorites";
import ErrorPage from "@/components/ErrorPage";
import Placeholder from "@/public/logo.webp";
export default function FavoritesPage() {
const t = useTranslations();
@@ -12,32 +15,39 @@ export default function FavoritesPage() {
if (isLoading) {
return (
<div className="container 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">
<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">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="w-full h-64 rounded-lg" />
<div key={i} className="space-y-2">
<Skeleton className="w-full h-[260px] rounded-xl" />
<Skeleton className="h-4 w-3/4 mx-2" />
<Skeleton className="h-6 w-1/2 mx-2" />
</div>
))}
</div>
</div>
);
}
if (isError || !favorites || favorites.length === 0) {
return (
<div className="container mx-auto px-6 py-8 bg-white">
<h1 className="text-3xl font-bold mb-6">{t("favorite_products")}</h1>
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-2xl text-gray-400">{t("empty_favorites")}</p>
</div>
</div>
);
if (isError) {
return <ErrorPage />;
}
if (!favorites || favorites.length === 0) {
return <EmptyFavorites />;
}
return (
<div className="container 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=" 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">
{favorites.map((favorite: Favorite) => {
const product = favorite.product;
@@ -50,7 +60,7 @@ export default function FavoritesPage() {
media.images_400x400 ||
media.thumbnail
)
.filter(Boolean) || ["/placeholder-product.jpg"];
.filter(Boolean) || [Placeholder];
const formattedPrice = product.price_amount
? `${parseFloat(product.price_amount).toFixed(2)} TMT`

View File

@@ -121,20 +121,22 @@
}
}
[data-sonner-toast] [data-description] {
color: #000 !important;
opacity: 0.9;
}
/* Animasyonları "utilities" katmanına ekliyoruz ki Tailwind sınıfları gibi davranabilsinler */
@layer utilities {
/* Özel Renk Sınıfları (CSS değişkenlerini kullanmak için) */
.text-fg { color: var(--fg); }
.bg-bg { background-color: var(--bg); }
.stroke-primary { stroke: #005bff; }
/* Dark mode track rengi için özel sınıf */
.text-fg {
color: var(--fg);
}
.bg-bg {
background-color: var(--bg);
}
.stroke-primary {
stroke: #005bff;
}
.stroke-track {
stroke: hsla(var(--hue), 10%, 10%, 0.1);
transition: stroke var(--trans-dur);
@@ -145,48 +147,90 @@
}
}
/* Animasyon Sınıfları */
.animate-msg { animation: msg 0.3s 13.7s linear forwards; }
.animate-msgLast { animation: msg 0.3s 14s linear reverse forwards; }
.animate-cartLines { animation: cartLines 2s ease-in-out infinite; }
.animate-cartTop { animation: cartTop 2s ease-in-out infinite; }
.animate-cartWheel1 {
.animate-msg {
animation: msg 0.3s 13.7s linear forwards;
}
.animate-msgLast {
animation: msg 0.3s 14s linear reverse forwards;
}
.animate-cartLines {
animation: cartLines 2s ease-in-out infinite;
}
.animate-cartTop {
animation: cartTop 2s ease-in-out infinite;
}
.animate-cartWheel1 {
animation: cartWheel1 2s ease-in-out infinite;
transform: rotate(-0.25turn);
transform-origin: 43px 111px;
}
.animate-cartWheel2 {
.animate-cartWheel2 {
animation: cartWheel2 2s ease-in-out infinite;
transform: rotate(0.25turn);
transform-origin: 102px 111px;
}
.animate-cartWheelStroke { animation: cartWheelStroke 2s ease-in-out infinite; }
.animate-cartWheelStroke {
animation: cartWheelStroke 2s ease-in-out infinite;
}
}
/* Keyframes Tanımları */
@keyframes msg {
from { opacity: 1; visibility: visible; }
99.9% { opacity: 0; visibility: visible; }
to { opacity: 0; visibility: hidden; }
from {
opacity: 1;
visibility: visible;
}
99.9% {
opacity: 0;
visibility: visible;
}
to {
opacity: 0;
visibility: hidden;
}
}
@keyframes cartLines {
from, to { opacity: 0; }
8%, 92% { opacity: 1; }
from,
to {
opacity: 0;
}
8%,
92% {
opacity: 1;
}
}
@keyframes cartTop {
from { stroke-dashoffset: -338; }
50% { stroke-dashoffset: 0; }
to { stroke-dashoffset: 338; }
from {
stroke-dashoffset: -338;
}
50% {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: 338;
}
}
@keyframes cartWheel1 {
from { transform: rotate(-0.25turn); }
to { transform: rotate(2.75turn); }
from {
transform: rotate(-0.25turn);
}
to {
transform: rotate(2.75turn);
}
}
@keyframes cartWheel2 {
from { transform: rotate(0.25turn); }
to { transform: rotate(3.25turn); }
from {
transform: rotate(0.25turn);
}
to {
transform: rotate(3.25turn);
}
}
@keyframes cartWheelStroke {
from, to { stroke-dashoffset: 81.68; }
50% { stroke-dashoffset: 40.84; }
}
from,
to {
stroke-dashoffset: 81.68;
}
50% {
stroke-dashoffset: 40.84;
}
}

View File

@@ -7,7 +7,7 @@ import "./globals.css"
import Header from "@/components/layout/Header"
import MobileBottomNav from "@/components/layout/MobileBar"
import { Toaster } from "@/components/ui/sonner"
import { Providers } from "@/context/provider"
import { Providers } from "@/context/Provider"
import AuthWrapper from "@/context/AuthWrapper"
const geistSans = Geist({

View File

@@ -14,7 +14,8 @@ import {
CardTitle,
} from "@/components/ui/card";
import { useOpenStore } from "@/lib/hooks";
import { useToast } from "@/hooks/use-toast";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
interface OpenStorePageProps {
locale?: string;
@@ -53,10 +54,7 @@ interface FormErrors {
file?: string;
}
export default function OpenStorePage({
locale = "ru",
translations,
}: OpenStorePageProps) {
export default function OpenStorePage({}: OpenStorePageProps) {
const [formData, setFormData] = useState<FormData>({
firstName: "",
lastName: "",
@@ -68,56 +66,39 @@ export default function OpenStorePage({
const [fileName, setFileName] = useState("");
const { mutate: submitOpenStore, isPending: loading } = useOpenStore();
const { toast } = useToast();
const t = translations || {
title: "Форма подачи заявления на открытие магазина",
firstName: "Имя",
lastName: "Фамилия",
email: "Email",
phone: "Телефон",
uploadPatent: "Загрузите патент на розничную торговлю (PDF, JPG)",
submit: "Отправить",
selectedFile: "Выбранный файл",
firstNameRequired: "Имя обязательно",
lastNameRequired: "Фамилия обязательна",
emailInvalid: "Некорректный email",
phoneInvalid: "Некорректный номер телефона",
fileRequired: "Патент обязателен",
fileSizeError: "Файл слишком большой (макс. 25MB)",
fileTypeError: "Только PDF и JPG документы",
};
const t = useTranslations();
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
if (!formData.firstName.trim()) {
newErrors.firstName = t.firstNameRequired;
newErrors.firstName = t("requiredField");
}
if (!formData.lastName.trim()) {
newErrors.lastName = t.lastNameRequired;
newErrors.lastName = t("requiredField");
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
newErrors.email = t.emailInvalid;
newErrors.email = t("requiredField");
}
const phoneRegex = /^\+?[0-9]{6,15}$/;
if (!phoneRegex.test(formData.phone)) {
newErrors.phone = t.phoneInvalid;
newErrors.phone = t("requiredField");
}
if (!formData.file) {
newErrors.file = t.fileRequired;
newErrors.file = t("fileRequired");
} else {
const allowedTypes = ["image/jpeg", "image/jpg", "application/pdf"];
if (!allowedTypes.includes(formData.file.type)) {
newErrors.file = t.fileTypeError;
newErrors.file = t("fileTypeError");
}
if (formData.file.size > 25 * 1024 * 1024) {
newErrors.file = t.fileSizeError;
newErrors.file = t("fileSizeError");
}
}
@@ -160,10 +141,8 @@ export default function OpenStorePage({
},
{
onSuccess: () => {
toast({
title: "Success",
description: "Your store request has been submitted successfully",
});
toast.success(t("submit_success"));
setFormData({
firstName: "",
lastName: "",
@@ -174,11 +153,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 || t("submit_error"));
},
}
);
@@ -189,7 +164,7 @@ export default function OpenStorePage({
<div className=" bg-gray-50 flex items-center justify-center p-4">
<Card className="w-full max-w-md shadow-lg">
<CardHeader>
<CardTitle className="text-2xl text-center">{t.title}</CardTitle>
<CardTitle className="text-2xl text-center">{t("title")}</CardTitle>
<CardDescription className="text-center">
Заполните форму для подачи заявления
</CardDescription>
@@ -198,7 +173,7 @@ export default function OpenStorePage({
<form onSubmit={handleSubmit} className="space-y-4">
{/* First Name */}
<div className="space-y-2">
<Label htmlFor="firstName">{t.firstName}</Label>
<Label htmlFor="firstName">{t("enter_first_name")}</Label>
<Input
id="firstName"
name="firstName"
@@ -213,7 +188,7 @@ export default function OpenStorePage({
{/* Last Name */}
<div className="space-y-2">
<Label htmlFor="lastName">{t.lastName}</Label>
<Label htmlFor="lastName">{t("enter_last_name")}</Label>
<Input
id="lastName"
name="lastName"
@@ -228,7 +203,7 @@ export default function OpenStorePage({
{/* Email */}
<div className="space-y-2">
<Label htmlFor="email">{t.email}</Label>
<Label htmlFor="email">{t("enter_email")}</Label>
<Input
id="email"
name="email"
@@ -244,7 +219,7 @@ export default function OpenStorePage({
{/* Phone */}
<div className="space-y-2">
<Label htmlFor="phone">{t.phone}</Label>
<Label htmlFor="phone">{t("enter_phone")}</Label>
<Input
id="phone"
name="phone"
@@ -260,7 +235,7 @@ export default function OpenStorePage({
{/* File Upload */}
<div className="space-y-2">
<Label htmlFor="file">{t.uploadPatent}</Label>
<Label htmlFor="file">{t("uploadPatent")}</Label>
<div className="flex flex-col gap-2">
<Input
id="file"
@@ -272,15 +247,15 @@ export default function OpenStorePage({
<Button
type="button"
variant="outline"
className="w-full bg-transparent"
className="w-full bg-transparent cursor-pointer"
onClick={() => document.getElementById("file")?.click()}
>
<Upload className="mr-2 h-4 w-4" />
{t.uploadPatent}
{t("uploadPatent")}
</Button>
{fileName && (
<p className="text-sm text-gray-600">
{t.selectedFile}: {fileName}
{t("selectedFile")}: {fileName}
</p>
)}
{errors.file && (
@@ -295,7 +270,7 @@ export default function OpenStorePage({
className="w-full cursor-pointer bg-[#005bff] hover:bg-[#0041c4]"
disabled={loading}
>
{loading ? "Загрузка..." : t.submit}
{loading ? t("submitting") : t("submit")}
</Button>
</form>
</CardContent>

View File

@@ -23,7 +23,6 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
}
export async function generateStaticParams() {
// Generate static params for popular products
return [{ slug: "nike-air-max" }, { slug: "adidas-ultraboost" }];
}

View File

@@ -0,0 +1,126 @@
"use client";
import React, { useEffect, useState } from "react";
// Google Fonts'u içe aktarmak için bileşen dışına ekliyoruz
const fontImport = `
@import url('https://fonts.googleapis.com/css?family=Encode+Sans+Semi+Condensed:100,200,300,400');
@keyframes clockwise {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@keyframes anticlockwise {
0% { transform: rotate(360deg); }
100% { transform: rotate(0deg); }
}
@keyframes clockwiseError {
0% { transform: rotate(0deg); }
20% { transform: rotate(30deg); }
40% { transform: rotate(25deg); }
60% { transform: rotate(30deg); }
100% { transform: rotate(0deg); }
}
@keyframes anticlockwiseError {
0% { transform: rotate(0deg); }
20% { transform: rotate(-30deg); }
40% { transform: rotate(-25deg); }
60% { transform: rotate(-30deg); }
100% { transform: rotate(0deg); }
}
@keyframes anticlockwiseErrorStop {
0% { transform: rotate(0deg); }
20% { transform: rotate(-30deg); }
60% { transform: rotate(-30deg); }
100% { transform: rotate(0deg); }
}
`;
export default function ErrorPage() {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => setIsLoading(false), 1000);
return () => clearTimeout(timer);
}, []);
return (
<div className="min-h-screen bg-white flex flex-col items-center justify-start overflow-hidden font-['Encode_Sans_Semi_Condensed',_sans-serif]">
<style dangerouslySetInnerHTML={{ __html: fontImport }} />
<h1
className={`text-[10rem] leading-40 font-extralight text-black transition-all duration-500 ease-linear
${isLoading ? "mt-0 opacity-0" : "mt-[100px] opacity-100"}`}
>
500
</h1>
<h2
className={`text-[1.5rem] font-extralight text-black mt-5 mb-[30px] transition-all duration-500 ease-linear
${isLoading ? "mt-0 opacity-0" : "opacity-100"}`}
>
Unexpected Error <b className="font-bold">:(</b>
</h2>
<div className="relative w-auto h-0">
{/* Gear One */}
<div
className="relative w-[120px] h-[120px] rounded-full bg-black mx-auto -left-[130px]
before:content-[''] before:absolute before:inset-[5px] before:bg-[#eaeaea] before:rounded-full before:z-20
after:content-[''] after:absolute after:inset-[25px] after:border-[5px] after:border-black after:rounded-full after:z-30 after:bg-[#eaeaea]"
style={{
animation: isLoading
? "clockwise 3s linear infinite"
: "anticlockwiseErrorStop 2s linear infinite",
}}
>
<GearBars />
</div>
{/* Gear Two */}
<div
className="relative w-[120px] h-[120px] rounded-full bg-black mx-auto -top-[75px]
before:content-[''] before:absolute before:inset-[5px] before:bg-[#eaeaea] before:rounded-full before:z-20
after:content-[''] after:absolute after:inset-[25px] after:border-[5px] after:border-black after:rounded-full after:z-30 after:bg-[#eaeaea]"
style={{
animation: isLoading
? "anticlockwise 3s linear infinite"
: "anticlockwiseError 2s linear infinite",
}}
>
<GearBars />
</div>
{/* Gear Three */}
<div
className="relative w-[120px] h-[120px] rounded-full bg-black mx-auto -top-[235px] left-[130px]
before:content-[''] before:absolute before:inset-[5px] before:bg-[#eaeaea] before:rounded-full before:z-20
after:content-[''] after:absolute after:inset-[25px] after:border-[5px] after:border-black after:rounded-full after:z-30 after:bg-[#eaeaea]"
style={{
animation: isLoading
? "clockwise 3s linear infinite"
: "clockwiseError 2s linear infinite",
}}
>
<GearBars />
</div>
</div>
</div>
);
}
function GearBars() {
return (
<>
<div className="absolute left-[-15px] top-1/2 w-[150px] h-[30px] -mt-[15px] rounded-[5px] bg-black z-0 before:content-[''] before:absolute before:inset-[5px] before:bg-[#eaeaea] before:rounded-[2px] before:z-[1]" />
<div
className="absolute left-[-15px] top-1/2 w-[150px] h-[30px] -mt-[15px] rounded-[5px] bg-black z-0 rotate-60 before:content-[''] before:absolute before:inset-[5px] before:bg-[#eaeaea] before:rounded-[2px] before:z-[1]"
style={{ transform: "rotate(60deg)" }}
/>
<div
className="absolute left-[-15px] top-1/2 w-[150px] h-[30px] -mt-[15px] rounded-[5px] bg-black z-0 rotate-120 before:content-[''] before:absolute before:inset-[5px] before:bg-[#eaeaea] before:rounded-[2px] before:z-[1]"
style={{ transform: "rotate(120deg)" }}
/>
</>
);
}

View File

@@ -1,9 +1,9 @@
import React from "react";
import { useTranslations } from "next-intl";
const Preloader: React.FC = () => {
const t =useTranslations();
return (
// bg-bg ve text-fg bizim CSS'te tanımladığımız değişkenleri kullanır.
// Standart Tailwind sınıflarını (flex, min-h-screen) düzen için kullanıyoruz.
<div className="flex flex-col items-center justify-center min-h-screen text-fg font-sans transition-colors duration-300">
<div className="text-center max-w-[20em] w-full">
@@ -21,15 +21,14 @@ const Preloader: React.FC = () => {
strokeLinejoin="round"
strokeWidth="8"
>
{/* Track (Arka plan izleri) */}
<g className="stroke-track">
<polyline points="4,4 21,4 26,22 124,22 112,64 35,64 39,80 106,80" />
<circle cx="43" cy="111" r="13" />
<circle cx="102" cy="111" r="13" />
</g>
{/* Hareketli Çizgiler */}
{/* animate-cartLines sınıfı globals.css'ten geliyor */}
<g className="stroke-primary animate-cartLines">
<polyline
className="animate-cartTop"
@@ -63,14 +62,14 @@ const Preloader: React.FC = () => {
</g>
</svg>
{/* Yükleniyor Yazıları */}
<div className="relative h-6">
<p className="absolute w-full animate-msg text-lg">
Bringing you the goods
{t('loading')}
</p>
<p className="absolute w-full opacity-0 invisible animate-msgLast text-lg">
{/* <p className="absolute w-full opacity-0 invisible animate-msgLast text-lg">
This is taking long. Somethings wrong.
</p>
</p> */}
</div>
</div>
</div>

View File

@@ -1,9 +1,10 @@
// Header.tsx
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import Image from "next/image";
import { X, Search, Store, User as UserIcon } from "lucide-react";
import { X, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import Logo from "@/public/logo.webp";
import CategoryMenu from "./ui/CategoryMenu";
@@ -12,10 +13,9 @@ import AuthDialog from "./ui/AuthDialog";
import ActionButtons from "./ui/ActionButtons";
import LanguageSelector from "./ui/LanguageSelector";
import MobileBottomNav from "./MobileBar";
import { useAuthStatus, useLogout } from "@/lib/hooks/useAuth";
import { useAuthStatus } from "@/lib/hooks/useAuth";
import { useTranslations } from "next-intl";
import { CategoryIcon } from "../icons";
import { useRouter } from "next/navigation";
interface HeaderProps {
locale?: string;
@@ -27,10 +27,8 @@ export default function Header({ locale = "ru" }: HeaderProps) {
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const [isLoginOpen, setIsLoginOpen] = useState(false);
const t = useTranslations();
const router = useRouter();
const { isAuthenticated, isLoading } = useAuthStatus();
const { mutate: logout, isPending: isLoggingOut } = useLogout();
const { isAuthenticated } = useAuthStatus();
useEffect(() => {
setIsClient(true);
@@ -44,10 +42,6 @@ export default function Header({ locale = "ru" }: HeaderProps) {
}
}, [isAuthenticated, locale]);
const handleLogout = useCallback(() => {
logout();
}, [logout]);
const toggleCategoryMenu = useCallback(() => {
setIsCategoryOpen((prev) => !prev);
}, []);
@@ -56,16 +50,12 @@ export default function Header({ locale = "ru" }: HeaderProps) {
setIsCategoryOpen(false);
}, []);
const handleProfileClick = useCallback(() => {
router.push(`/${locale}/me`);
}, [router, locale]);
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]">
@@ -80,15 +70,16 @@ export default function Header({ locale = "ru" }: HeaderProps) {
</Link>
<Button
data-catalog-trigger
onClick={toggleCategoryMenu}
className="hidden gap-2 rounded-lg font-bold sm:flex hover:bg-[#005bff] bg-[#005bff] text-white"
className="cursor-pointer 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 />}
{t("common.catalog")}
</Button>
<div className="flex items-center gap-2 sm:hidden">
<div className="flex items-center gap-2 sm:hidden cursor-pointer">
<Button
variant="ghost"
size="icon"
@@ -131,10 +122,11 @@ export default function Header({ locale = "ru" }: HeaderProps) {
<AuthDialog isOpen={isLoginOpen} onClose={() => setIsLoginOpen(false)} />
<MobileBottomNav
locale={locale}
onLoginClick={() => setIsLoginOpen(true)}
/>
locale={locale}
onLoginClick={() => {
setIsLoginOpen(true);
}}
/>
</>
);
}

View File

@@ -12,10 +12,13 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useCategories, useCart, useFavorites, useOrders } from "@/lib/hooks";
import { useCategories, useFavorites, useOrders } from "@/lib/hooks";
import { useCartCount } from "@/features/cart/hooks/useCart";
import { useRouter } from "next/navigation";
import { useAuthStatus } from "@/lib/hooks/useAuth";
import { useTranslations } from "next-intl";
import AuthDialog from "./ui/AuthDialog";
interface MobileBottomNavProps {
locale?: string;
translations?: {
@@ -36,29 +39,26 @@ export default function MobileBottomNav({
}: MobileBottomNavProps) {
const [isClient, setIsClient] = useState(false);
const [isCategoryOpen, setIsCategoryOpen] = useState(false);
const t = useTranslations();
// AUTH STATE DIRECTLY FROM HOOK - NOT FROM PROPS
const [isLoginOpen, setIsLoginOpen] = useState(false);
const t = useTranslations();
const { isAuthenticated, isLoading: authLoading } = useAuthStatus();
const { data: categories = [] } = useCategories();
const { data: cartData } = useCart();
const cartCount = useCartCount();
const { data: favoritesData } = useFavorites();
const { data: ordersData } = useOrders();
const router = useRouter();
useEffect(() => {
setIsClient(true);
}, []);
const handleProfileClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (authLoading) {
return;
@@ -69,13 +69,14 @@ export default function MobileBottomNav({
} else {
if (onLoginClick) {
onLoginClick();
} else {
setIsLoginOpen(true);
}
}
};
const handleNavigation = (path: string) => (e: React.MouseEvent) => {
e.preventDefault();
console.log("[MobileBottomNav] Navigating to:", path);
router.push(path);
};
@@ -84,7 +85,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
@@ -92,7 +93,6 @@ export default function MobileBottomNav({
size="sm"
className="flex-col gap-0.5 h-auto px-2 py-2"
onClick={() => {
console.log("[MobileBottomNav] Catalog clicked");
setIsCategoryOpen(true);
}}
>
@@ -109,14 +109,18 @@ export default function MobileBottomNav({
>
<div className="relative">
<Heart className="h-5 w-5 text-gray-600" />
<Badge
variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
>
{favoritesData?.length || 0}
</Badge>
{(favoritesData?.length || 0) > 0 && (
<Badge
variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
>
{favoritesData?.length}
</Badge>
)}
</div>
<span className="text-xs text-gray-700">{t("common.favorites")}</span>
<span className="text-xs text-gray-700">
{t("common.favorites")}
</span>
</Button>
{/* Orders Button */}
@@ -128,17 +132,19 @@ export default function MobileBottomNav({
>
<div className="relative">
<Truck className="h-5 w-5 text-gray-600" />
<Badge
variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
>
{ordersData?.length || 0}
</Badge>
{(ordersData?.length || 0) > 0 && (
<Badge
variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
>
{ordersData?.length}
</Badge>
)}
</div>
<span className="text-xs text-gray-700">{t("common.orders")}</span>
</Button>
{/* Cart Button */}
{/* Cart Button - OPTIMIZED */}
<Button
variant="ghost"
size="sm"
@@ -147,12 +153,14 @@ export default function MobileBottomNav({
>
<div className="relative">
<ShoppingCart className="h-5 w-5 text-gray-600" />
<Badge
variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
>
{cartData?.data?.length || 0}
</Badge>
{cartCount > 0 && (
<Badge
variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
>
{cartCount}
</Badge>
)}
</div>
<span className="text-xs text-gray-700">{t("common.cart")}</span>
</Button>
@@ -167,7 +175,11 @@ export default function MobileBottomNav({
>
<User className="h-5 w-5 text-gray-600" />
<span className="text-xs text-gray-700">
{authLoading ? "..." : (isAuthenticated ? t("profile") : t("login"))}
{authLoading
? "..."
: isAuthenticated
? t("common.profile")
: t("common.login")}
</span>
</Button>
</div>
@@ -212,6 +224,9 @@ export default function MobileBottomNav({
</ScrollArea>
</SheetContent>
</Sheet>
{/* Local Auth Dialog */}
<AuthDialog isOpen={isLoginOpen} onClose={() => setIsLoginOpen(false)} />
</>
);
}
}

View File

@@ -12,7 +12,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useCart, useFavorites, useOrders } from "@/lib/hooks";
import { useCart, useFavorites, useOrders, useCartCount } from "@/lib/hooks";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "next-intl";
import { useLogout } from "@/lib/hooks/useAuth";
@@ -53,10 +53,7 @@ export default function ActionButtons({
const { data: ordersData, isLoading: ordersLoading } = useOrders();
// Calculate cart count from cart items array
const cartCount = useMemo(() => {
if (!cartData?.data) return 0;
return cartData.data.length;
}, [cartData]);
const cartCount = useCartCount()
// Calculate favorites count
const favoritesCount = useMemo(() => {
@@ -70,8 +67,13 @@ export default function ActionButtons({
return Array.isArray(ordersData) ? ordersData.length : 0;
}, [ordersData]);
const handleLogout = () => {
logout();
const handleLogout = () => {
logout(undefined, {
onSuccess: () => {
router.push(`/${locale}`);
router.refresh();
}
});
};
const buttons: ActionButtonData[] = useMemo(
@@ -115,7 +117,7 @@ export default function ActionButtons({
);
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" />
@@ -125,7 +127,7 @@ export default function ActionButtons({
<Button
variant="ghost"
size="sm"
className="flex-col gap-0.5 h-auto px-2 py-2"
className="flex-col cursor-pointer gap-0.5 h-auto px-2 py-2"
>
<ProfileIcon />
<span className="text-xs text-gray-700">{t("profile")}</span>
@@ -146,7 +148,7 @@ export default function ActionButtons({
<Button
variant="ghost"
size="sm"
className="flex-col gap-0.5 h-auto px-2 py-2"
className="flex-col cursor-pointer gap-0.5 h-auto px-2 py-2"
onClick={onAuthClick}
>
<ProfileIcon />

View File

@@ -4,7 +4,12 @@ import { useState, useCallback } from "react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import Logo from "@/public/logo.webp";
import { useLogin, useVerifyToken } from "@/lib/hooks/useAuth";
@@ -16,10 +21,9 @@ interface AuthDialogProps {
}
export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
const [phone, setPhone] = useState("993");
const [phone, setPhone] = useState("+993 ");
const [otp, setOtp] = useState("");
const [otpSent, setOtpSent] = useState(false);
const [rawPhone, setRawPhone] = useState("");
const t = useTranslations();
const { mutate: login, isPending: isLoginLoading } = useLogin();
@@ -27,25 +31,55 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
const resetDialog = useCallback(() => {
setOtpSent(false);
setPhone("993");
setPhone("+993 ");
setOtp("");
setRawPhone("");
onClose();
}, [onClose]);
const formatPhoneForBackend = (phoneNumber: string): string => {
return phoneNumber.replace(/^\+993\s*/, "").replace(/\s+/g, "");
};
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target.value;
const prefix = "+993 ";
if (input.length < prefix.length) {
setPhone(prefix);
return;
}
const digitsOnly = input.substring(prefix.length).replace(/\D/g, "");
const limitedDigits = digitsOnly.substring(0, 8);
let formattedPhone = prefix;
if (limitedDigits.length > 0) {
formattedPhone += limitedDigits.substring(0, 2);
if (limitedDigits.length > 2) {
formattedPhone += " " + limitedDigits.substring(2);
}
}
setPhone(formattedPhone);
};
const isPhoneValid = (): boolean => {
const phoneDigits = formatPhoneForBackend(phone);
return phoneDigits.length === 8;
};
const handleSendOtp = useCallback(() => {
const cleanPhone = phone.replace(/\D/g, "");
if (cleanPhone.length !== 11 || !cleanPhone.startsWith("993")) {
if (!isPhoneValid()) {
toast.error(t("invalid_phone"));
return;
}
const phoneNumber = cleanPhone.substring(3);
setRawPhone(phoneNumber);
const phoneNumber = formatPhoneForBackend(phone);
login(
{ phone_number: phoneNumber },
{ phone_number: parseInt(phoneNumber, 10) },
{
onSuccess: () => {
toast.success(t("code_sent"));
@@ -64,10 +98,12 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
return;
}
const phoneNumber = formatPhoneForBackend(phone);
verifyToken(
{
phone_number: rawPhone,
code: otp,
phone_number: parseInt(phoneNumber, 10),
code: parseInt(otp, 10),
},
{
onSuccess: () => {
@@ -80,21 +116,16 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
},
}
);
}, [otp, rawPhone, verifyToken, resetDialog, t]);
}, [otp, phone, verifyToken, resetDialog, t]);
const handleKeyPress = useCallback((e: React.KeyboardEvent, action: () => void) => {
if (e.key === "Enter") {
action();
}
}, []);
const formatPhoneInput = useCallback((value: string) => {
const cleaned = value.replace(/\D/g, "");
if (!cleaned.startsWith("993")) {
return "993";
}
return cleaned.substring(0, 11);
}, []);
const handleKeyPress = useCallback(
(e: React.KeyboardEvent, action: () => void) => {
if (e.key === "Enter") {
action();
}
},
[]
);
return (
<Dialog open={isOpen} onOpenChange={resetDialog}>
@@ -105,21 +136,24 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
<Image src={Logo} alt="Logo" fill className="object-contain" />
</div>
</div>
<DialogTitle className="text-2xl text-center">{t("common.enterPhone")}</DialogTitle>
<p className="text-center text-sm text-gray-600">{t("common.weWillSendCode")}</p>
<DialogTitle className="text-2xl text-center">
{t("common.enterPhone")}
</DialogTitle>
<p className="text-center text-sm text-gray-600">
{t("common.weWillSendCode")}
</p>
</DialogHeader>
<div className="space-y-4 mt-4">
<div>
<Input
type="tel"
placeholder={t("common.phone")}
placeholder="+993 61 097651"
value={phone}
onChange={(e) => setPhone(formatPhoneInput(e.target.value))}
onChange={handlePhoneChange}
className="h-12 rounded-xl"
onKeyDown={(e) => handleKeyPress(e, handleSendOtp)}
disabled={otpSent || isLoginLoading}
maxLength={11}
/>
<p className="text-xs text-gray-500 mt-1">{t("phone_format")}</p>
</div>
@@ -129,7 +163,9 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
type="text"
placeholder={t("common.code")}
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, "").substring(0, 6))}
onChange={(e) =>
setOtp(e.target.value.replace(/\D/g, "").substring(0, 6))
}
className="h-12 rounded-xl"
onKeyDown={(e) => handleKeyPress(e, handleLogin)}
disabled={isVerifyLoading}
@@ -140,20 +176,22 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
<Button
onClick={otpSent ? handleLogin : handleSendOtp}
className="w-full h-12 rounded-xl font-bold text-base bg-[#005bff] hover:bg-[#0041c4]"
className="w-full cursor-pointer h-12 rounded-xl font-bold text-base bg-[#005bff] hover:bg-[#0041c4]"
size="lg"
disabled={isLoginLoading || isVerifyLoading}
disabled={
isLoginLoading || isVerifyLoading || (!otpSent && !isPhoneValid())
}
>
{isLoginLoading
? t("sending")
: isVerifyLoading
? t("verifying")
: otpSent
? t("verify")
: t("common.send")}
? t("verifying")
: otpSent
? t("verify")
: t("common.send")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
}

View File

@@ -1,80 +1,149 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useCategories } from "@/lib/hooks"
import { Skeleton } from "@/components/ui/skeleton"
// CategoryMenu.tsx
"use client";
import { useState, useEffect, useRef } from "react";
import Link from "next/link";
import { useCategories } from "@/lib/hooks";
import { Skeleton } from "@/components/ui/skeleton";
interface CategoryMenuProps {
isOpen: boolean
onClose: () => void
isOpen: boolean;
onClose: () => void;
}
export default function CategoryMenu({ isOpen, onClose }: CategoryMenuProps) {
const [hoveredCategory, setHoveredCategory] = useState<number | null>(null)
const { data: categories, isLoading } = useCategories()
const [hoveredCategory, setHoveredCategory] = useState<number | null>(null);
const { data: categories, isLoading } = useCategories();
const menuRef = useRef<HTMLDivElement>(null);
if (!isOpen) return null
// Click outside to close
useEffect(() => {
if (!isOpen) return;
const categoryList = categories || []
const activeCategory = hoveredCategory !== null ? categoryList[hoveredCategory] : null
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target.closest("[data-catalog-trigger]")) {
return;
}
if (menuRef.current && !menuRef.current.contains(target)) {
onClose();
}
};
// Add listener after a small delay to prevent immediate closing
const timeoutId = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
}, 100);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isOpen, onClose]);
// ESC key to close
useEffect(() => {
if (!isOpen) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
}
};
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
const categoryList = categories || [];
const activeCategory =
hoveredCategory !== null ? categoryList[hoveredCategory] : null;
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="flex">
<CategoryList
categories={categoryList}
isLoading={isLoading}
onCategoryHover={setHoveredCategory}
onCategoryClick={onClose}
/>
<>
<div className="fixed inset-0 bg-black/20 z-30" onClick={onClose} />
{activeCategory?.children && <SubcategoryList category={activeCategory} onSubcategoryClick={onClose} />}
{/* Menu */}
<div
ref={menuRef}
className="fixed left-0 right-0 top-16 z-40 bg-white border-b rounded-b-lg shadow-lg max-w-[1504px] mx-auto"
>
<div className="mx-auto px-4">
<div className="flex">
<CategoryList
categories={categoryList}
isLoading={isLoading}
onCategoryHover={setHoveredCategory}
onCategoryClick={onClose}
/>
{activeCategory?.children && (
<SubcategoryList
category={activeCategory}
onSubcategoryClick={onClose}
/>
)}
</div>
</div>
</div>
</div>
)
</>
);
}
interface CategoryListProps {
categories: any[]
isLoading: boolean
onCategoryHover: (index: number) => void
onCategoryClick: () => void
categories: any[];
isLoading: boolean;
onCategoryHover: (index: number) => void;
onCategoryClick: () => void;
}
function CategoryList({ categories, isLoading, onCategoryHover, onCategoryClick }: CategoryListProps) {
function CategoryList({
categories,
isLoading,
onCategoryHover,
onCategoryClick,
}: CategoryListProps) {
return (
<div className="w-[280px] border-r">
<div className="max-h-[calc(100vh-4rem)] overflow-y-auto py-2">
{isLoading
? [1, 2, 3, 4, 5].map((i) => <Skeleton key={i} className="h-10 mx-4 my-2 rounded" />)
? [1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} className="h-10 mx-4 my-2 rounded" />
))
: categories.map((category, index) => (
<Link
key={category.id}
href={`/category/${category.slug}?category_id=${category.id}`}
onClick={onCategoryClick}
onMouseEnter={() => onCategoryHover(index)}
className="flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-gray-100 hover:text-primary transition-colors"
className="flex items-center gap-3 px-4 py-3 rounded-lg hover:bg-gray-100 hover:text-primary transition-colors"
>
{category.icon_class && <i className={`${category.icon_class} text-xl`}></i>}
{category.icon_class && (
<i className={`${category.icon_class} text-xl`} />
)}
<span>{category.name}</span>
</Link>
))}
</div>
</div>
)
);
}
interface SubcategoryListProps {
category: any
onSubcategoryClick: () => void
category: any;
onSubcategoryClick: () => void;
}
function SubcategoryList({ category, onSubcategoryClick }: SubcategoryListProps) {
function SubcategoryList({
category,
onSubcategoryClick,
}: SubcategoryListProps) {
return (
<div className="flex-1 p-6">
<h3 className="text-xl font-semibold mb-4">{category.name}</h3>
@@ -91,5 +160,5 @@ function SubcategoryList({ category, onSubcategoryClick }: SubcategoryListProps)
))}
</div>
</div>
)
);
}

View File

@@ -92,9 +92,9 @@ export default function SearchBar({
<button
key={product.id}
onClick={() => handleProductClick(product.id)}
className="w-full flex items-center gap-3 p-3 hover:bg-gray-50 transition-colors border-b last:border-b-0"
className="w-full cursor-pointer flex items-center gap-3 p-3 hover:bg-gray-50 transition-colors border-b last:border-b-0"
>
<div className="relative w-16 h-16 flex-shrink-0">
<div className="relative w-16 h-16 shrink-0">
<Image
src={product.thumbnail}
alt={product.name}
@@ -157,7 +157,7 @@ export default function SearchBar({
</div>
<Button
size="icon"
className="h-auto hover:bg-[#005bff] cursor-pointer bg-transparent flex items-center mr-1.5 text-white"
className="h-auto hover:bg-[#005bff] cursor-pointer bg-transparent flex items-center mr-1.5 text-white"
>
<SearchIcon />
</Button>

View File

@@ -14,7 +14,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer border-[#0041c4] dark:bg-input/30 data-[state=checked]:bg-[#005bff] data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-[#0041c4] focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}

View File

@@ -45,7 +45,7 @@ function Slider({
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
"bg-[#005bff] absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>

View File

@@ -1,4 +1,4 @@
"use client"
"use client";
import {
CircleCheckIcon,
@@ -6,12 +6,12 @@ import {
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
} from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
const { theme = "system" } = useTheme();
return (
<Sonner
@@ -24,17 +24,24 @@ const Toaster = ({ ...props }: ToasterProps) => {
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
toastOptions={{
classNames: {
description: "text-foreground opacity-90",
title: "font-bold",
},
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
"--description-color": "var(--popover-foreground)",
} as React.CSSProperties
}
{...props}
/>
)
}
);
};
export { Toaster }
export { Toaster };

View File

@@ -1,10 +1,13 @@
// components/AuthWrapper.tsx
"use client";
import { useEffect, type ReactNode } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useRouter } 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;
@@ -20,48 +23,37 @@ export default function AuthWrapper({
locale,
}: AuthWrapperProps) {
const router = useRouter();
const pathname = usePathname();
const { isAuthenticated, isLoading } = useAuthStatus();
const { mutate: getGuestToken, isPending: isGettingGuestToken } = useGetGuestToken();
// Login olmuş kullanıcı için profil bilgisini otomatik çek
const { mutate: getGuestToken, isPending: isGettingGuestToken } =
useGetGuestToken();
useUserProfile();
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]);
useEffect(() => {
if (isLoading || isGettingGuestToken) return;
if (requireAuth && !isAuthenticated) {
const redirect = redirectTo || `/${locale}/login`;
const returnUrl = pathname !== redirect ? `?returnUrl=${encodeURIComponent(pathname)}` : "";
router.push(`${redirect}${returnUrl}`);
router.push(`/${locale}`);
return;
}
if (isAuthenticated && (pathname.includes("/login") || pathname.includes("/register"))) {
router.push(`/${locale}`);
}
}, [isAuthenticated, isLoading, requireAuth, pathname, router, locale, redirectTo, isGettingGuestToken]);
}, [
isAuthenticated,
isLoading,
requireAuth,
router,
locale,
isGettingGuestToken,
]);
if (isLoading || (requireAuth && !isAuthenticated)) {
return (
<Preloader/>
);
return <Preloader />;
}
return <>{children}</>;
}
}

View File

@@ -1,20 +1,22 @@
"use client"
"use client";
import { QueryClientProvider, HydrationBoundary } from "@tanstack/react-query"
import { queryClient } from "@/lib/queryClient"
import type { ReactNode } from "react"
import {
QueryClientProvider,
HydrationBoundary,
type DehydratedState,
} from "@tanstack/react-query";
import { queryClient } from "@/lib/queryClient";
import type { ReactNode } from "react";
interface ProvidersProps {
children: ReactNode
dehydratedState?: unknown
children: ReactNode;
dehydratedState?: DehydratedState;
}
export function Providers({ children, dehydratedState }: ProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={dehydratedState}>
{children}
</HydrationBoundary>
<HydrationBoundary state={dehydratedState}>{children}</HydrationBoundary>
</QueryClientProvider>
)
}
);
}

View File

@@ -1,312 +1,344 @@
"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, 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()
// Local UI State (Instant feedback)
const [localQuantity, setLocalQuantity] = useState(item.quantity)
// Sync State
const [isSyncing, setIsSyncing] = useState(false)
const [syncError, setSyncError] = useState(false)
// Stock limit modal
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)
// Function refs to solve circular dependency
const syncToServerRef = useRef<((quantity: number) => void) | null>(null)
const retrySyncRef = useRef<((quantity: number) => void) | null>(null)
const t = useTranslations();
const { mutate: updateQuantity } = useUpdateCartItemQuantity()
const { mutate: removeItem, isPending: isRemoving } = useRemoveFromCart()
// Local UI State (Instant feedback)
const [localQuantity, setLocalQuantity] = useState(item.quantity);
// Sync State
const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState(false);
// Stock limit modal
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);
// Function refs to solve circular dependency
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();
// 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) => {
try {
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
const savePendingUpdate = useCallback(
(quantity: number) => {
try {
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,
};
sessionStorage.setItem(
PENDING_CART_UPDATES_KEY,
JSON.stringify(pending)
);
} catch (error) {
console.error("Failed to save pending update:", error);
}
sessionStorage.setItem(PENDING_CART_UPDATES_KEY, JSON.stringify(pending))
} catch (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) => {
// If already syncing, queue this update
if (isRequestInFlightRef.current) {
pendingQuantityRef.current = quantity
return
}
const syncToServer = useCallback(
(quantity: number) => {
// If already syncing, queue this update
if (isRequestInFlightRef.current) {
pendingQuantityRef.current = quantity;
return;
}
// Mark as syncing
isRequestInFlightRef.current = true
setIsSyncing(true)
setSyncError(false)
// Mark as syncing
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?.()
// Process queued update if any
if (pendingQuantityRef.current !== null) {
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)
}
})
} else {
updateQuantity(
{ productId: item.product_id, quantity },
{
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('Update failed:', error)
isRequestInFlightRef.current = false
// Rollback on error after retries exhausted
if (retryCountRef.current >= 3) {
setLocalQuantity(item.quantity)
clearPendingUpdate()
}
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?.();
// Process queued update if any
if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current;
pendingQuantityRef.current = null;
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100);
}
},
onError: (error) => {
console.error("Update failed:", error);
isRequestInFlightRef.current = false;
// Rollback on error after retries exhausted
if (retryCountRef.current >= 3) {
setLocalQuantity(item.quantity);
clearPendingUpdate();
}
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 (
<>
<Card className="p-4 shadow-none border">
<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" />
<div className="relative w-[88px] h-[117px] rounded-xl border overflow-hidden shrink-0">
<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 })}
@@ -317,7 +349,7 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
size="sm"
onClick={handleDelete}
disabled={isRemoving}
className="w-fit p-0 h-auto hover:bg-transparent hover:text-red-500"
className="w-fit cursor-pointer p-0 h-auto hover:bg-transparent hover:text-red-500"
>
<Trash2 className="h-5 w-5" />
</Button>
@@ -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,28 +387,34 @@ 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-pointer rounded-lg bg-blue-50 ${
isSyncing ? "opacity-70" : ""
}`}
>
<Minus className="h-4 w-4" />
</Button>
<div className="w-12 text-center font-semibold relative">
{localQuantity}
{isSyncing && (
<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>
<Button
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-lg cursor-pointer bg-blue-50 ${
isSyncing ? "opacity-70" : ""
} ${
localQuantity >= availableStock
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<Plus className="h-4 w-4 text-[#007AFF]" />
@@ -390,16 +437,16 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
{t("stock_limit_title")}
</DialogTitle>
<DialogDescription className="text-center text-base pt-2">
{t("stock_limit_message", {
{t("stock_limit_message", {
product: item.product.name,
stock: availableStock
stock: availableStock,
})}
</DialogDescription>
</DialogHeader>
<div className="flex justify-center mt-4">
<Button
onClick={() => setShowStockModal(false)}
className="w-full rounded-xl"
className="w-full rounded-lg cursor-pointer"
>
{t("understood")}
</Button>
@@ -407,5 +454,5 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
</DialogContent>
</Dialog>
</>
)
}
);
}

View File

@@ -1,27 +1,36 @@
import { Skeleton } from "@/components/ui/skeleton"
import { Card } from "@/components/ui/card"
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
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" />
<Card className="p-4 shadow-none border">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex gap-4 flex-1">
<Skeleton className="w-[88px] h-[117px] rounded-xl" />
<div className="flex flex-col gap-2 flex-1">
<Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-5 w-5 rounded" />
</div>
</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 className="flex flex-col sm:flex-row items-start sm:items-center gap-4 justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-24" />
<Skeleton className="h-8 w-40 rounded-lg" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-10 w-10 rounded-xl" />
<Skeleton className="h-6 w-12" />
<Skeleton className="h-10 w-10 rounded-xl" />
</div>
</div>
</div>
</Card>
)
);
}

View File

@@ -1,32 +1,30 @@
import { ShoppingCart } from "lucide-react"
import { Button } from "@/components/ui/button"
import Link from "next/link"
import { Button } from "@/components/ui/button";
import { ShoppingCart } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
interface EmptyCartProps {
locale?: string
message?: string
actionText?: string
actionHref?: string
}
export default function EmptyCart({
locale = "ru",
message = "Your cart is empty",
actionText = "Start Shopping",
actionHref = "/",
}: EmptyCartProps) {
export default function EmptyCart() {
const t=useTranslations();
const router=useRouter();
return (
<div className="min-h-[400px] flex flex-col items-center justify-center p-4">
<ShoppingCart className="h-16 w-16 text-gray-300 mb-4" />
<h2 className="text-2xl font-semibold text-gray-600 mb-2">{message}</h2>
<p className="text-gray-500 mb-6 text-center max-w-sm">
{locale === "ru"
? "Добавьте товары в корзину, чтобы начать покупки"
: "Add items to your cart to start shopping"}
</p>
<Link href={actionHref}>
<Button className="rounded-xl">{actionText}</Button>
</Link>
<div className="flex min-h-[60vh] items-center justify-center px-4">
<div className="w-full max-w-md rounded-2xl bg-linear-to-br from-blue-50 to-white p-8 text-center shadow-lg">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-blue-100">
<ShoppingCart className="h-10 w-10 text-blue-600" />
</div>
<h2 className="mb-2 text-2xl font-semibold text-gray-900">
{t("cart_empty")}
</h2>
<p className="mb-6 text-sm text-gray-500">
{t("cart_empty_message")}
</p>
<Button onClick={()=>router.push("/")} className="w-full cursor-pointer rounded-xl bg-blue-600 px-6 py-3 text-sm font-medium text-white transition hover:bg-blue-700 active:scale-95">
{t("start_shopping")}
</Button>
</div>
</div>
)
}
);
}

View File

@@ -16,6 +16,7 @@ import { Input } from "@/components/ui/input";
import DeliveryTypeSelector from "./DeliveryTypeSelector";
import { useTranslations } from "next-intl";
import type { DeliveryType, PaymentType, Province } from "@/lib/types/api";
import { useState } from "react";
interface OrderBillingItem {
title: string;
@@ -83,15 +84,57 @@ export default function OrderSummary({
isLoading,
}: OrderSummaryProps) {
const t = useTranslations();
const [showValidation, setShowValidation] = useState(false);
const provincesForSelectedRegion = selectedRegion
? regionGroups[selectedRegion] || []
: [];
const phoneDigits = phone.replace(/\D/g, "");
const isPhoneValid = phoneDigits.length === 11;
const isFormValid =
selectedRegion && selectedProvince && paymentType && phone && name;
selectedRegion &&
selectedProvince &&
paymentType &&
isPhoneValid &&
name.trim() !== "" &&
lastName.trim() !== "";
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target.value;
const prefix = "+993 ";
if (input.length < prefix.length) {
onPhoneChange(prefix);
return;
}
const digitsOnly = input.substring(prefix.length).replace(/\D/g, "");
const limitedDigits = digitsOnly.substring(0, 8);
let formattedPhone = prefix;
if (limitedDigits.length > 0) {
formattedPhone += limitedDigits.substring(0, 2);
if (limitedDigits.length > 2) {
formattedPhone += " " + limitedDigits.substring(2);
}
}
onPhoneChange(formattedPhone);
};
const handleCompleteOrderClick = () => {
setShowValidation(true);
if (isFormValid) {
onCompleteOrder();
}
};
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">
<h3 className="text-lg font-semibold mb-3">
@@ -107,8 +150,13 @@ export default function OrderSummary({
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder={t("name")}
className="rounded-xl"
className={`rounded-lg ${
showValidation && name.trim() === "" ? "border-red-500" : ""
}`}
/>
{showValidation && name.trim() === "" && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
<div>
<Label className="text-sm font-medium mb-2 block">
@@ -119,8 +167,13 @@ export default function OrderSummary({
value={lastName}
onChange={(e) => onLastNameChange(e.target.value)}
placeholder={t("last_name")}
className="rounded-xl"
className={`rounded-lg ${
showValidation && lastName.trim() === "" ? "border-red-500" : ""
}`}
/>
{showValidation && lastName.trim() === "" && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
<div>
<Label className="text-sm font-medium mb-2 block">
@@ -129,10 +182,17 @@ export default function OrderSummary({
<Input
type="tel"
value={phone}
onChange={(e) => onPhoneChange(e.target.value)}
placeholder={t("phone")}
className="rounded-xl"
onChange={handlePhoneChange}
placeholder="+993 61 097651"
className={`rounded-lg ${
showValidation && !isPhoneValid ? "border-red-500" : ""
}`}
/>
{showValidation && !isPhoneValid && (
<p className="text-xs text-red-500 mt-1">
{t("requiredField")}
</p>
)}
</div>
</div>
</div>
@@ -147,6 +207,8 @@ export default function OrderSummary({
className={`flex-1 cursor-pointer transition-all ${
paymentType?.id === type.id
? "border-2 border-[#005bff] bg-blue-50"
: showValidation && !paymentType
? "border-2 border-red-500"
: "border-2 border-gray-200"
}`}
onClick={() => onPaymentTypeChange(type)}
@@ -163,14 +225,11 @@ export default function OrderSummary({
</Card>
))}
</div>
{showValidation && !paymentType && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
{/* Delivery Type */}
<DeliveryTypeSelector
selectedType={deliveryType}
onSelect={onDeliveryTypeChange}
/>
{/* Region Selection */}
<div className="mb-6">
<Label className="text-lg font-semibold mb-3 block">
@@ -189,7 +248,11 @@ export default function OrderSummary({
<RadioGroupItem
value={regionCode}
id={`region-${regionCode}`}
className="border-2 border-gray-400 data-[state=checked]:border-[#005bff] data-[state=checked]:bg-white"
className={`border-2 ${
showValidation && !selectedRegion
? "border-red-500"
: "border-gray-400"
} data-[state=checked]:border-[#005bff] data-[state=checked]:bg-white`}
/>
<Label
htmlFor={`region-${regionCode}`}
@@ -200,6 +263,9 @@ export default function OrderSummary({
</div>
))}
</RadioGroup>
{showValidation && !selectedRegion && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
{/* Province Selection */}
@@ -212,7 +278,11 @@ export default function OrderSummary({
value={selectedProvince?.toString() || ""}
onValueChange={(value) => onProvinceChange(parseInt(value))}
>
<SelectTrigger className="rounded-xl">
<SelectTrigger
className={`rounded-lg w-full ${
showValidation && !selectedProvince ? "border-red-500" : ""
}`}
>
<SelectValue placeholder={t("choose_address")} />
</SelectTrigger>
<SelectContent>
@@ -223,6 +293,9 @@ export default function OrderSummary({
))}
</SelectContent>
</Select>
{showValidation && !selectedProvince && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
)}
@@ -263,9 +336,9 @@ export default function OrderSummary({
</div>
<Button
onClick={onCompleteOrder}
disabled={!isFormValid || isLoading}
className="w-full rounded-xl bg-[#005bff] hover:bg-[#004dcc] h-12 text-lg font-bold disabled:opacity-50"
onClick={handleCompleteOrderClick}
disabled={isLoading}
className="w-full rounded-lg cursor-pointer bg-[#005bff] hover:bg-[#004dcc] h-12 text-lg font-bold disabled:opacity-50"
size="lg"
>
{isLoading ? `${t("order")}...` : t("order")}

View File

@@ -0,0 +1,81 @@
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
export default function OrderSummarySkeleton() {
return (
<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">
<Skeleton className="h-6 w-48 mb-3" />
<div className="space-y-4">
<div>
<Skeleton className="h-4 w-20 mb-2" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
<div>
<Skeleton className="h-4 w-24 mb-2" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
<div>
<Skeleton className="h-4 w-16 mb-2" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
</div>
</div>
{/* Payment Type */}
<div className="mb-6">
<Skeleton className="h-6 w-32 mb-3" />
<div className="flex gap-2">
<Skeleton className="flex-1 h-20 rounded-lg" />
<Skeleton className="flex-1 h-20 rounded-lg" />
</div>
</div>
{/* Region Selection */}
<div className="mb-6">
<Skeleton className="h-6 w-36 mb-3" />
<div className="flex flex-wrap gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</div>
{/* Province Selection */}
<div className="mb-6">
<Skeleton className="h-6 w-40 mb-3" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
{/* Note */}
<div className="mb-6">
<Skeleton className="h-6 w-24 mb-3" />
<Skeleton className="h-24 w-full rounded-xl" />
</div>
{/* Billing */}
<div className="space-y-2 mb-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex justify-between">
<Skeleton className="h-5 w-24" />
<Skeleton className="h-5 w-20" />
</div>
))}
</div>
<Separator className="my-4" />
<div className="flex justify-between items-center mb-6">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-6 w-28" />
</div>
<Skeleton className="h-12 w-full rounded-lg" />
</Card>
);
}

View File

@@ -1,148 +1,307 @@
import { useQuery, useMutation, useQueryClient, UseQueryOptions } from "@tanstack/react-query"
import { apiClient } from "@/lib/api"
import type { CartItem } from "@/lib/types/api"
import {
useQuery,
useMutation,
useQueryClient,
UseQueryOptions,
} from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import type { CartItem } from "@/lib/types/api";
import { useEffect } from "react";
interface CartResponse {
message: string
data: CartItem[]
errorDetails?: string
message: string;
data: CartItem[];
errorDetails?: string;
}
// Transform response to handle HTML/malformed responses
const pendingUpdates = new Map<number, number>();
let updateLock = false;
class CartEventEmitter {
private listeners: Set<() => void> = new Set();
subscribe(callback: () => void) {
this.listeners.add(callback);
return () => {
this.listeners.delete(callback);
};
}
emit() {
this.listeners.forEach((cb) => cb());
}
}
export const cartEvents = new CartEventEmitter();
function transformCartResponse(response: any): CartResponse {
if (
typeof response === "string" &&
(response.trim().startsWith("<!DOCTYPE") || response.trim().startsWith("<html"))
(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
}
return { message: "success", data: [] }
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 { message: "error", data: [] }
return JSON.parse(response);
} catch {
return { message: "error", data: [] };
}
}
return { message: "unknown", data: [] }
return { message: "unknown", data: [] };
}
export function useCart(options?: Partial<UseQueryOptions<CartResponse>>) {
return useQuery({
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["cart"],
queryFn: async () => {
const response = await apiClient.get("/carts")
return transformCartResponse(response.data)
const response = await apiClient.get("/carts");
const transformed = transformCartResponse(response.data);
return transformed;
},
refetchInterval: 10000, // Increased to 10 seconds (less aggressive)
refetchOnMount: true,
refetchOnWindowFocus: true, // Enable to catch updates on tab focus
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
staleTime: 5000, // Data considered fresh for 5 seconds
staleTime: Infinity,
gcTime: 1000 * 60 * 5,
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
...options,
})
});
useEffect(() => {
const unsubscribe = cartEvents.subscribe(() => {
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "none",
});
});
return () => {
unsubscribe();
};
}, [queryClient]);
return query;
}
export function useAddToCart() {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ productId, quantity = 1 }: { productId: number; quantity?: number }) => {
mutationFn: async ({
productId,
quantity = 1,
}: {
productId: number;
quantity?: number;
}) => {
const params = new URLSearchParams({
product_id: String(productId),
product_quantity: String(quantity),
})
});
const response = await apiClient.post("/carts", params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
})
});
if (typeof response.data === "object" && response.data.data) {
return response.data
return response.data;
}
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 { message: "success", data: "Added to cart" }
return JSON.parse(response.data);
} catch {
return { message: "success", data: "Added to cart" };
}
}
return { message: "success", data: "Added to cart" }
return { message: "success", data: "Added to cart" };
},
onSuccess: () => {
// Invalidate but don't refetch immediately (let polling handle it)
queryClient.invalidateQueries({ queryKey: ["cart"], refetchType: 'none' })
onMutate: async ({ productId, quantity }) => {
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;
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) {
updated.data = updated.data.map((item: any) =>
item.product?.id === productId
? {
...item,
product_quantity: item.product_quantity + quantity,
}
: item
);
} else {
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);
}
return updated;
});
cartEvents.emit();
updateLock = false;
return { previousCart };
},
onError: (error: any) => {
console.error("Add to cart error:", error.response?.data?.message || error.message)
onError: (error, variables, context) => {
if (context?.previousCart) {
queryClient.setQueryData(["cart"], context.previousCart);
pendingUpdates.delete(variables.productId);
cartEvents.emit();
}
},
})
onSuccess: (data, variables) => {
pendingUpdates.delete(variables.productId);
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "active",
});
},
});
}
export function useRemoveFromCart() {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (productId: number) => {
const params = new URLSearchParams({ product_id: String(productId) })
const params = new URLSearchParams({ product_id: String(productId) });
const response = await apiClient.patch("/carts", params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
})
});
if (typeof response.data === "object" && response.data.data) {
return response.data.data
return response.data.data;
}
if (typeof response.data === "string") {
try {
const parsed = JSON.parse(response.data)
return parsed.data || []
} catch (error) {
console.error("Failed to parse cart response:", error)
return []
const parsed = JSON.parse(response.data);
return parsed.data || [];
} catch {
return [];
}
}
return []
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;
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);
return updated;
});
cartEvents.emit();
updateLock = false;
return { previousCart };
},
onError: (error, variables, context) => {
if (context?.previousCart) {
queryClient.setQueryData(["cart"], context.previousCart);
cartEvents.emit();
}
},
onSuccess: () => {
// Immediate refetch after removal
queryClient.invalidateQueries({ queryKey: ["cart"] })
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "active",
});
},
onError: (error: any) => {
console.error("Remove from cart error:", error.response?.data?.message || error.message)
},
})
});
}
export function useCleanCart() {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
@@ -150,98 +309,200 @@ export function useCleanCart() {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
})
});
if (typeof response.data === "object" && response.data.data) {
return response.data.data
return response.data.data;
}
if (typeof response.data === "string") {
try {
const parsed = JSON.parse(response.data)
return parsed.data || []
} catch (error) {
console.error("Failed to parse cart response:", error)
return []
const parsed = JSON.parse(response.data);
return parsed.data || [];
} catch {
return [];
}
}
return []
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 };
},
onError: (error, variables, context) => {
if (context?.previousCart) {
queryClient.setQueryData(["cart"], context.previousCart);
cartEvents.emit();
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] })
queryClient.invalidateQueries({ queryKey: ["cart"] });
},
})
});
}
export function useUpdateCartItemQuantity() {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ productId, quantity }: { productId: number; quantity: number }) => {
mutationFn: async ({
productId,
quantity,
}: {
productId: number;
quantity: number;
}) => {
const params = new URLSearchParams({
product_id: String(productId),
product_quantity: String(quantity),
})
});
const response = await apiClient.post("/carts", params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
timeout: 15000, // 15 second timeout
})
timeout: 15000,
});
if (typeof response.data === "object" && response.data.data) {
return response.data
return response.data;
}
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 { message: "success", data: "Updated cart" }
return JSON.parse(response.data);
} catch {
return { message: "success", data: "Updated cart" };
}
}
return { message: "success", data: "Updated cart" }
return { message: "success", data: "Updated cart" };
},
onSuccess: () => {
// Invalidate but don't refetch immediately (let optimistic update handle it)
queryClient.invalidateQueries({ queryKey: ["cart"], refetchType: 'none' })
onMutate: async ({ productId, quantity }) => {
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;
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);
return updated;
});
cartEvents.emit();
updateLock = false;
return { previousCart };
},
onError: (error: any) => {
console.error("API update failed:", error.response?.data?.message || error.message)
throw error // Re-throw to trigger retry mechanism
onError: (error, variables, context) => {
if (context?.previousCart) {
queryClient.setQueryData(["cart"], context.previousCart);
pendingUpdates.delete(variables.productId);
cartEvents.emit();
}
throw error;
},
})
onSuccess: (data, variables) => {
pendingUpdates.delete(variables.productId);
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "none",
});
},
});
}
export function useCreateOrder() {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: {
customer_name?: string
customer_phone: string
customer_address: string
shipping_method: string
payment_type_id: number
delivery_time?: string
delivery_at?: string
region: string
note?: string
customer_name?: string;
customer_phone: number;
customer_address: string;
shipping_method: string;
payment_type_id: number;
delivery_time?: string;
delivery_at?: string;
region: string;
note?: string;
}) => {
const response = await apiClient.post("/orders", payload)
return response.data
const response = await apiClient.post("/orders", payload);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] })
queryClient.invalidateQueries({ queryKey: ["orders"] })
onSuccess: (data) => {
if (data && data.payment_url) {
window.open(data.payment_url, '_blank')?.focus();
}
pendingUpdates.clear();
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
if (!old) return old;
return { ...old, data: [] };
});
cartEvents.emit();
queryClient.invalidateQueries({ queryKey: ["orders"] });
},
onError: (error: any) => {
console.error("Create order error:", error.response?.data?.message || error.message)
console.error(
"Create order error:",
error.response?.data?.message || error.message
);
},
})
}
});
}
export function useCartCount() {
const { data } = useCart();
return (
data?.data?.reduce(
(sum: number, item: any) => sum + (item.product_quantity || 0),
0
) || 0
);
}

View File

@@ -77,7 +77,7 @@ export default function CategoryFilters({
</FilterSection>
)}
<FilterSection title={translations.sort}>
{/* <FilterSection title={translations.sort}>
<RadioItem
name="sort"
checked={priceSort === "none"}
@@ -96,7 +96,7 @@ export default function CategoryFilters({
onChange={() => onPriceSortChange("highToLow")}
label={translations.price_high_to_low}
/>
</FilterSection>
</FilterSection> */}
<PriceFilter
title={translations.price}
@@ -108,7 +108,7 @@ export default function CategoryFilters({
}}
/>
<Button variant="outline" className="w-full rounded-xl" onClick={onReset}>
<Button variant="outline" className="w-full rounded-lg cursor-pointer mb-6" onClick={onReset}>
{translations.reset}
</Button>
</div>

View File

@@ -28,7 +28,7 @@ export default function CategoryFiltersSheet({
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetTrigger asChild>
<Button
className="sm:hidden fixed bottom-20 right-4 rounded-xl font-bold gap-2 z-10 shadow-lg"
className="bg-[#005bff] hover:bg-[#0041c4] sm:hidden fixed bottom-20 right-4 rounded-lg cursor-pointer font-bold gap-2 z-10 shadow-lg"
size="lg"
>
{filterLabel}
@@ -40,7 +40,7 @@ export default function CategoryFiltersSheet({
<SheetTitle>{filterLabel}</SheetTitle>
<button
onClick={() => onOpenChange(false)}
className="absolute top-4 right-4 rounded-md ring-offset-background transition-opacity hover:opacity-100"
className="absolute top-4 right-4 rounded-md cursor-pointer ring-offset-background transition-opacity hover:opacity-100"
>
<X className="h-4 w-4" />
<span className="sr-only">{closeLabel}</span>

View File

@@ -2,6 +2,7 @@
import { useEffect, useState, useMemo, useCallback } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import {
useCategories,
useCategoryFilters,
@@ -12,6 +13,7 @@ import type { Category, Product } from "@/lib/types/api";
import CategoryFilters from "./CategoryFilters";
import CategoryProductsGrid from "./CategoryProductsGrid";
import CategoryFiltersSheet from "./CategoryFiltersSheet";
import ErrorPage from "@/components/ErrorPage";
interface CategoryPageClientProps {
params: { locale: string; slug: string };
@@ -24,8 +26,11 @@ export default function CategoryPageClient({
const t = useTranslations();
const [isSheetOpen, setIsSheetOpen] = useState(false);
const { data: categoriesData, isLoading: categoriesLoading } =
useCategories();
const {
data: categoriesData,
isLoading: categoriesLoading,
isError: categoriesError
} = useCategories();
const selectedCategory = useMemo(() => {
if (!categoriesData || !slug) return null;
@@ -57,7 +62,11 @@ export default function CategoryPageClient({
>(new Set());
// Fetch filters
const { data: filtersData } = useCategoryFilters(selectedCategory?.id, {
const {
data: filtersData,
isLoading: filtersLoading,
isError: filtersError
} = useCategoryFilters(selectedCategory?.id, {
enabled: !!selectedCategory,
});
@@ -76,19 +85,18 @@ export default function CategoryPageClient({
params.categories = Array.from(selectedFilterCategories);
}
if (priceRange[0] > 0) {
params.min_price = priceRange[0];
}
if (priceRange[1] < 10000) {
params.max_price = priceRange[1];
}
params.min_price = priceRange[0];
params.max_price = priceRange[1];
return params;
}, [currentPage, selectedBrands, selectedFilterCategories, priceRange]);
// Fetch filtered products
const { data: productsData, isFetching } = useFilteredCategoryProducts(
const {
data: productsData,
isFetching,
isError: productsError
} = useFilteredCategoryProducts(
selectedCategory?.id?.toString() || "",
filterParams,
{ enabled: !!selectedCategory }
@@ -106,22 +114,19 @@ export default function CategoryPageClient({
}
}, [selectedCategory?.id]);
// Update products list - BU KISIM ÖNEMLİ!
// Update products list
useEffect(() => {
if (productsData?.data) {
setAllProducts((prev) => {
// İlk sayfa ise direkt replace et
if (currentPage === 1) {
return productsData.data;
}
// Sonraki sayfalar için deduplicate et
const existingIds = new Set(prev.map((p) => p.id));
const newProducts = productsData.data.filter(
(p: Product) => !existingIds.has(p.id)
);
// Eğer yeni ürün yoksa, return prev (gereksiz re-render önlenir)
if (newProducts.length === 0) {
return prev;
}
@@ -129,16 +134,13 @@ export default function CategoryPageClient({
return [...prev, ...newProducts];
});
}
}, [productsData?.data, currentPage]); // productsData yerine productsData.data
}, [productsData?.data, currentPage]);
// hasMore hesaplama - BU KISIM DA ÖNEMLİ!
const hasMore = useMemo(() => {
if (!productsData?.pagination) return false;
// pagination.next_page_url varsa devam et
if (productsData.pagination.next_page_url) return true;
// Alternatif olarak: current_page < last_page kontrolü
if (
productsData.pagination.current_page &&
productsData.pagination.last_page
@@ -148,7 +150,6 @@ export default function CategoryPageClient({
);
}
// Alternatif 2: hasMorePages flag'i varsa
if (productsData.pagination.hasMorePages !== undefined) {
return productsData.pagination.hasMorePages;
}
@@ -158,9 +159,8 @@ export default function CategoryPageClient({
const loadMoreData = useCallback(() => {
if (!hasMore || isFetching) return;
console.log("Loading page:", currentPage + 1); // Debug için
setCurrentPage((prev) => prev + 1);
}, [hasMore, isFetching, currentPage]);
}, [hasMore, isFetching]);
const sortedProducts = useMemo(() => {
const products = [...allProducts];
@@ -240,9 +240,57 @@ export default function CategoryPageClient({
[t]
);
if (categoriesLoading) return <div>{t("common.loading")}</div>;
if (!selectedCategory)
// ERROR STATE
if (categoriesError || productsError || filtersError) {
return <ErrorPage />;
}
// LOADING STATE
if (categoriesLoading) {
return (
<div className="flex flex-col mx-auto max-w-[1504px] px-2 md:px-4 lg:px-6 pb-12">
{/* Title Skeleton */}
<Skeleton className="h-16 w-full rounded-t-lg mb-0 bg-white" />
<div className="flex p-2 md:p-4 gap-4 bg-white rounded-b-lg mt-0">
{/* Desktop Filters Skeleton */}
<div className="hidden sm:block w-[280px] shrink-0 border-r px-4 space-y-6">
<Skeleton className="h-8 w-32" />
<div className="space-y-2">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-8 w-32" />
<div className="space-y-2">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
{/* Products Grid Skeleton */}
<div className="flex-1">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="w-full aspect-square rounded-lg" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-6 w-1/2" />
</div>
))}
</div>
</div>
</div>
</div>
);
}
// CATEGORY NOT FOUND
if (!selectedCategory) {
return <div className="text-center py-8">{t("category_not_found")}</div>;
}
return (
<div className="flex flex-col mx-auto max-w-[1504px] px-2 md:px-4 lg:px-6 pb-12">
@@ -250,23 +298,41 @@ export default function CategoryPageClient({
{selectedCategory.name}
</h2>
<div className="flex gap-4 bg-white rounded-b-lg">
<div className="flex p-2 md:p-4 gap-4 bg-white rounded-b-lg">
{/* Desktop Filters Sidebar */}
<div className="hidden sm:block w-[280px] shrink-0 border-r px-4">
<ScrollArea className="h-auto">
<CategoryFilters
filtersData={filtersData}
selectedBrands={selectedBrands}
selectedFilterCategories={selectedFilterCategories}
priceSort={priceSort}
priceRange={priceRange}
onBrandToggle={handleBrandToggle}
onCategoryToggle={handleCategoryToggle}
onPriceSortChange={handlePriceSortChange}
onPriceChange={handlePriceChange}
onReset={resetFilters}
translations={filterTranslations}
/>
{filtersLoading ? (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
) : (
<CategoryFilters
filtersData={filtersData}
selectedBrands={selectedBrands}
selectedFilterCategories={selectedFilterCategories}
priceSort={priceSort}
priceRange={priceRange}
onBrandToggle={handleBrandToggle}
onCategoryToggle={handleCategoryToggle}
onPriceSortChange={handlePriceSortChange}
onPriceChange={handlePriceChange}
onReset={resetFilters}
translations={filterTranslations}
/>
)}
</ScrollArea>
</div>
@@ -292,20 +358,38 @@ export default function CategoryPageClient({
filterLabel={t("filter")}
closeLabel={t("close")}
>
<CategoryFilters
filtersData={filtersData}
selectedBrands={selectedBrands}
selectedFilterCategories={selectedFilterCategories}
priceSort={priceSort}
priceRange={priceRange}
onBrandToggle={handleBrandToggle}
onCategoryToggle={handleCategoryToggle}
onPriceSortChange={handlePriceSortChange}
onPriceChange={handlePriceChange}
onReset={resetFilters}
translations={filterTranslations}
/>
{filtersLoading ? (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
) : (
<CategoryFilters
filtersData={filtersData}
selectedBrands={selectedBrands}
selectedFilterCategories={selectedFilterCategories}
priceSort={priceSort}
priceRange={priceRange}
onBrandToggle={handleBrandToggle}
onCategoryToggle={handleCategoryToggle}
onPriceSortChange={handlePriceSortChange}
onPriceChange={handlePriceChange}
onReset={resetFilters}
translations={filterTranslations}
/>
)}
</CategoryFiltersSheet>
</div>
);
}
}

View File

@@ -6,7 +6,7 @@ interface CategoryProductsGridProps {
products: Product[];
hasMore: boolean;
onLoadMore: () => void;
isFetching?: boolean; // Yeni prop - loading durumu için
isFetching?: boolean;
translations: {
loading: string;
no_results: string;
@@ -46,7 +46,6 @@ export default function CategoryProductsGrid({
endMessage={
products.length > 0 && !hasMore ? (
<div className="text-center py-4 text-gray-500 text-sm">
{/* Opsiyonel: "Tüm ürünler yüklendi" mesajı */}
</div>
) : null
}
@@ -68,7 +67,6 @@ export default function CategoryProductsGrid({
))}
</div>
{/* İlk yükleme için skeleton göster */}
{isFetching && products.length === 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 mt-3">
{Array.from({ length: 6 }).map((_, i) => (

View File

@@ -1,17 +1,17 @@
import { Skeleton } from "@/components/ui/skeleton"
import { Card } from "@/components/ui/card"
import { CardContent } from "@/components/ui/card"
// import { Skeleton } from "@/components/ui/skeleton"
// import { Card } from "@/components/ui/card"
// import { CardContent } from "@/components/ui/card"
export default function CategorySkeleton() {
return (
<Card className="overflow-hidden rounded-xl">
{/* Image */}
<Skeleton className="w-full h-36 bg-gray-200" />
// export default function CategorySkeleton() {
// return (
// <Card className="overflow-hidden rounded-xl">
// {/* Image */}
// <Skeleton className="w-full h-36 bg-gray-200" />
{/* Name */}
<CardContent className="py-2">
<Skeleton className="h-4 w-3/4 bg-gray-200" />
</CardContent>
</Card>
)
}
// {/* Name */}
// <CardContent className="py-2">
// <Skeleton className="h-4 w-3/4 bg-gray-200" />
// </CardContent>
// </Card>
// )
// }

View File

@@ -107,7 +107,7 @@ export default function CollectionFilters({
}}
/>
<Button variant="outline" className="w-full rounded-xl" onClick={onReset}>
<Button variant="outline" className="w-full rounded-lg cursor-pointer mb-6" onClick={onReset}>
{translations.reset}
</Button>
</div>

View File

@@ -28,7 +28,7 @@ export default function CollectionFiltersSheet({
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetTrigger asChild>
<Button
className="sm:hidden fixed bottom-20 right-4 rounded-xl font-bold gap-2 z-10 shadow-lg"
className="bg-[#005bff] hover:bg-[#0041c4] sm:hidden fixed bottom-20 right-4 rounded-lg cursor-pointer font-bold gap-2 z-10 shadow-lg"
size="lg"
>
{filterLabel}
@@ -40,7 +40,7 @@ export default function CollectionFiltersSheet({
<SheetTitle>{filterLabel}</SheetTitle>
<button
onClick={() => onOpenChange(false)}
className="absolute top-4 right-4 rounded-md ring-offset-background transition-opacity hover:opacity-100"
className="absolute top-4 right-4 rounded-md cursor-pointer ring-offset-background transition-opacity hover:opacity-100"
>
<X className="h-4 w-4" />
<span className="sr-only">{closeLabel}</span>

View File

@@ -2,16 +2,18 @@
import { useEffect, useState, useMemo, useCallback } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useTranslations } from "next-intl";
import type { Product } from "@/lib/types/api";
import CollectionFilters from "./CollectionFilters";
import CollectionProductsGrid from "./CollectionProductsGrid";
import CollectionFiltersSheet from "./CollectionFiltersSheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
useCollections,
useCollectionFilters,
useFilteredCollectionProducts,
} from "@/features/collections/hooks/useCollections";
import { useTranslations } from "next-intl";
import type { Product } from "@/lib/types/api";
import CollectionFilters from "./CollectionFilters";
import CollectionProductsGrid from "./CollectionProductsGrid";
import CollectionFiltersSheet from "./CollectionFiltersSheet";
import ErrorPage from "@/components/ErrorPage";
interface CollectionPageClientProps {
params: { locale: string; slug: string };
@@ -24,8 +26,11 @@ export default function CollectionPageClient({
const t = useTranslations();
const [isSheetOpen, setIsSheetOpen] = useState(false);
const { data: collectionsData, isLoading: collectionsLoading } =
useCollections();
const {
data: collectionsData,
isLoading: collectionsLoading,
isError: collectionsError,
} = useCollections();
const selectedCollection = useMemo(() => {
if (!collectionsData || !slug) return null;
@@ -35,13 +40,21 @@ export default function CollectionPageClient({
// State management
const [currentPage, setCurrentPage] = useState(1);
const [allProducts, setAllProducts] = useState<Product[]>([]);
const [priceSort, setPriceSort] = useState<"none" | "lowToHigh" | "highToLow">("none");
const [priceSort, setPriceSort] = useState<
"none" | "lowToHigh" | "highToLow"
>("none");
const [priceRange, setPriceRange] = useState<[number, number]>([0, 10000]);
const [selectedBrands, setSelectedBrands] = useState<Set<number>>(new Set());
const [selectedCategories, setSelectedCategories] = useState<Set<number>>(new Set());
const [selectedCategories, setSelectedCategories] = useState<Set<number>>(
new Set()
);
// Fetch filters
const { data: filtersData } = useCollectionFilters(selectedCollection?.id, {
const {
data: filtersData,
isLoading: filtersLoading,
isError: filtersError,
} = useCollectionFilters(selectedCollection?.id, {
enabled: !!selectedCollection,
});
@@ -60,19 +73,18 @@ export default function CollectionPageClient({
params.categories = Array.from(selectedCategories);
}
if (priceRange[0] > 0) {
params.min_price = priceRange[0];
}
if (priceRange[1] < 10000) {
params.max_price = priceRange[1];
}
params.min_price = priceRange[0];
params.max_price = priceRange[1];
return params;
}, [currentPage, selectedBrands, selectedCategories, priceRange]);
// Fetch filtered products
const { data: productsData, isFetching } = useFilteredCollectionProducts(
const {
data: productsData,
isFetching,
isError: productsError,
} = useFilteredCollectionProducts(
selectedCollection?.id?.toString() || "",
filterParams,
{ enabled: !!selectedCollection }
@@ -97,14 +109,20 @@ export default function CollectionPageClient({
if (currentPage === 1) {
return productsData.data;
}
const existingIds = new Set(prev.map((p) => p.id));
const newProducts = productsData.data.filter(
(p: Product) => !existingIds.has(p.id)
);
if (newProducts.length === 0) {
return prev;
}
return [...prev, ...newProducts];
});
}
}, [productsData, currentPage]);
}, [productsData?.data, currentPage]);
const hasMore = useMemo(() => {
return !!productsData?.pagination?.next_page_url;
@@ -115,6 +133,7 @@ export default function CollectionPageClient({
setCurrentPage((prev) => prev + 1);
}, [hasMore, isFetching]);
// Client-side sorting
const sortedProducts = useMemo(() => {
const products = [...allProducts];
if (priceSort === "lowToHigh") {
@@ -146,7 +165,9 @@ export default function CollectionPageClient({
const handleCategoryToggle = useCallback((categoryId: number) => {
setSelectedCategories((prev) => {
const newSet = new Set(prev);
newSet.has(categoryId) ? newSet.delete(categoryId) : newSet.add(categoryId);
newSet.has(categoryId)
? newSet.delete(categoryId)
: newSet.add(categoryId);
return newSet;
});
setCurrentPage(1);
@@ -191,9 +212,63 @@ export default function CollectionPageClient({
[t]
);
if (collectionsLoading) return <div>{t("common.loading")}</div>;
if (!selectedCollection)
// ERROR STATE
if (collectionsError || productsError || filtersError) {
return <ErrorPage />;
}
// LOADING STATE
if (collectionsLoading) {
return (
<div className="flex flex-col mx-auto max-w-[1504px] px-2 md:px-4 lg:px-6 pb-12">
{/* Title Skeleton */}
<Skeleton className="h-16 w-full rounded-t-lg mb-0 bg-white" />
<div className="flex p-2 md:p-4 gap-4 bg-white rounded-b-lg mt-0">
{/* Desktop Filters Skeleton */}
<div className="hidden sm:block w-[280px] shrink-0 border-r px-4 space-y-6">
<Skeleton className="h-8 w-32" />
<div className="space-y-2">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-8 w-32" />
<div className="space-y-2">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-8 w-32" />
<div className="space-y-2">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
{/* Products Grid Skeleton */}
<div className="flex-1">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="w-full aspect-square rounded-lg" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-6 w-1/2" />
</div>
))}
</div>
</div>
</div>
</div>
);
}
// COLLECTION NOT FOUND
if (!selectedCollection) {
return <div className="text-center py-8">{t("collection_not_found")}</div>;
}
return (
<div className="flex flex-col mx-auto max-w-[1504px] px-2 md:px-4 lg:px-6 pb-12">
@@ -201,23 +276,47 @@ export default function CollectionPageClient({
{selectedCollection.name}
</h2>
<div className="flex gap-4 bg-white rounded-b-lg">
<div className="flex p-2 md:p-4 gap-4 bg-white rounded-b-lg">
{/* Desktop Filters Sidebar */}
<div className="hidden sm:block w-[280px] shrink-0 border-r px-4">
<ScrollArea className="h-auto">
<CollectionFilters
filtersData={filtersData}
selectedBrands={selectedBrands}
selectedCategories={selectedCategories}
priceSort={priceSort}
priceRange={priceRange}
onBrandToggle={handleBrandToggle}
onCategoryToggle={handleCategoryToggle}
onPriceSortChange={handlePriceSortChange}
onPriceChange={handlePriceChange}
onReset={resetFilters}
translations={filterTranslations}
/>
{filtersLoading ? (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
) : (
<CollectionFilters
filtersData={filtersData}
selectedBrands={selectedBrands}
selectedCategories={selectedCategories}
priceSort={priceSort}
priceRange={priceRange}
onBrandToggle={handleBrandToggle}
onCategoryToggle={handleCategoryToggle}
onPriceSortChange={handlePriceSortChange}
onPriceChange={handlePriceChange}
onReset={resetFilters}
translations={filterTranslations}
/>
)}
</ScrollArea>
</div>
@@ -227,6 +326,7 @@ export default function CollectionPageClient({
products={sortedProducts}
hasMore={hasMore}
onLoadMore={loadMoreData}
isFetching={isFetching}
translations={{
loading: t("common.loading"),
no_results: t("no_results"),
@@ -242,20 +342,44 @@ export default function CollectionPageClient({
filterLabel={t("filter")}
closeLabel={t("close")}
>
<CollectionFilters
filtersData={filtersData}
selectedBrands={selectedBrands}
selectedCategories={selectedCategories}
priceSort={priceSort}
priceRange={priceRange}
onBrandToggle={handleBrandToggle}
onCategoryToggle={handleCategoryToggle}
onPriceSortChange={handlePriceSortChange}
onPriceChange={handlePriceChange}
onReset={resetFilters}
translations={filterTranslations}
/>
{filtersLoading ? (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
) : (
<CollectionFilters
filtersData={filtersData}
selectedBrands={selectedBrands}
selectedCategories={selectedCategories}
priceSort={priceSort}
priceRange={priceRange}
onBrandToggle={handleBrandToggle}
onCategoryToggle={handleCategoryToggle}
onPriceSortChange={handlePriceSortChange}
onPriceChange={handlePriceChange}
onReset={resetFilters}
translations={filterTranslations}
/>
)}
</CollectionFiltersSheet>
</div>
);
}
}

View File

@@ -5,6 +5,7 @@ import type { Product } from "@/lib/types/api";
interface CollectionProductsGridProps {
products: Product[];
hasMore: boolean;
isFetching?: boolean;
onLoadMore: () => void;
translations: {
loading: string;
@@ -16,9 +17,10 @@ export default function CollectionProductsGrid({
products,
hasMore,
onLoadMore,
isFetching = false,
translations,
}: CollectionProductsGridProps) {
if (products.length === 0) {
if (products.length === 0 && !isFetching) {
return (
<div className="text-center py-8 text-gray-500">
{translations.no_results}
@@ -35,9 +37,17 @@ export default function CollectionProductsGrid({
style={{ overflow: "visible" }}
loader={
<div className="flex justify-center py-4">
<div>{translations.loading}</div>
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
<span>{translations.loading}</span>
</div>
</div>
}
endMessage={
products.length > 0 && !hasMore ? (
<div className="text-center py-4 text-gray-500 text-sm"></div>
) : null
}
>
<div className="bg-white rounded-lg grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{products.map((product) => (
@@ -55,6 +65,18 @@ export default function CollectionProductsGrid({
/>
))}
</div>
{isFetching && products.length === 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 mt-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="bg-gray-200 h-48 rounded-lg mb-2" />
<div className="bg-gray-200 h-4 rounded w-3/4 mb-2" />
<div className="bg-gray-200 h-4 rounded w-1/2" />
</div>
))}
</div>
)}
</InfiniteScroll>
);
}
}

View File

@@ -1,32 +1,30 @@
import { Heart } from "lucide-react"
import { Button } from "@/components/ui/button"
import Link from "next/link"
import { Button } from "@/components/ui/button";
import { Heart } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
interface EmptyFavoritesProps {
locale?: string
message?: string
actionText?: string
actionHref?: string
}
export default function EmptyFavorites({
locale = "ru",
message = "No favorite items yet",
actionText = "Browse Products",
actionHref = "/",
}: EmptyFavoritesProps) {
export default function EmptyFavorites() {
const t=useTranslations();
const router=useRouter();
return (
<div className="min-h-[400px] flex flex-col items-center justify-center p-4">
<Heart className="h-16 w-16 text-gray-300 mb-4" />
<h2 className="text-2xl font-semibold text-gray-600 mb-2">{message}</h2>
<p className="text-gray-500 mb-6 text-center max-w-sm">
{locale === "ru"
? "Сохраняйте понравившиеся товары, чтобы найти их позже"
: "Save items you love to find them later"}
</p>
<Link href={actionHref}>
<Button className="rounded-xl">{actionText}</Button>
</Link>
<div className="flex min-h-[60vh] items-center justify-center px-4">
<div className="w-full max-w-md rounded-2xl bg-linear-to-br from-blue-50 to-white p-8 text-center shadow-lg">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-blue-100">
<Heart className="h-10 w-10 text-blue-600" />
</div>
<h2 className="mb-2 text-2xl font-semibold text-gray-900">
{t("favorites_empty")}
</h2>
<p className="mb-6 text-sm text-gray-500">
{t("favorites_empty_message")}
</p>
<Button onClick={()=>router.push("/")} className="w-full rounded-lg cursor-pointer bg-blue-600 px-6 py-3 text-sm font-medium text-white transition hover:bg-blue-700 active:scale-95">
{t("start_shopping")}
</Button>
</div>
</div>
)
}
);
}

View File

@@ -27,6 +27,7 @@ async function fetchAllFavorites(): Promise<Favorite[]> {
const allFavorites: Favorite[] = [];
let currentPage = 1;
let hasMorePages = true;
let lastError: Error | null = null;
while (hasMorePages) {
try {
@@ -37,7 +38,6 @@ async function fetchAllFavorites(): Promise<Favorite[]> {
const favorites = transformFavoritesResponse(response.data);
allFavorites.push(...favorites);
// Check pagination
const pagination = response.data?.pagination;
if (pagination?.next_page_url) {
currentPage++;
@@ -45,11 +45,22 @@ async function fetchAllFavorites(): Promise<Favorite[]> {
hasMorePages = false;
}
} catch (error) {
// If pagination not supported, return what we have
if (currentPage === 1) {
throw error;
}
lastError = error as Error;
hasMorePages = false;
}
}
if (allFavorites.length === 0 && lastError) {
throw lastError;
}
return allFavorites;
}

View File

@@ -1,38 +1,54 @@
"use client"
import Image, { type StaticImageData } from "next/image"
import { Swiper, SwiperSlide } from "swiper/react"
import { Autoplay } from "swiper/modules"
import "swiper/css"
"use client";
import Image, { type StaticImageData } from "next/image";
import Link from "next/link";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay } from "swiper/modules";
import "swiper/css";
type CarouselItem = {
title: string
image: StaticImageData | string
url?: string | null
}
title: string;
image: StaticImageData | string;
url?: string | null;
};
export default function HeroCarousel({ items }: { items: CarouselItem[] }) {
return (
<section className="rounded-2xl overflow-hidden">
<Swiper
modules={[Autoplay]}
slidesPerView={1}
loop
<Swiper
modules={[Autoplay]}
slidesPerView={1}
loop
autoplay={{ delay: 3000, disableOnInteraction: false }}
>
{items.map((item, i) => (
<SwiperSlide key={i}>
<div className="relative w-full h-[200px] sm:h-[300px] md:h-[496px]">
<Image
src={item.image}
alt={item.title}
fill
className="object-cover"
priority={i === 0}
/>
</div>
{item.url ? (
<Link
href={item.url}
className="block relative w-full h-[200px] sm:h-[300px] md:h-[496px]"
>
<Image
src={item.image}
alt={item.title}
fill
className="object-cover"
priority={i === 0}
/>
</Link>
) : (
<div className="relative w-full h-[200px] sm:h-[300px] md:h-[496px]">
<Image
src={item.image}
alt={item.title}
fill
className="object-cover"
priority={i === 0}
/>
</div>
)}
</SwiperSlide>
))}
</Swiper>
</section>
)
}
);
}

View File

@@ -3,6 +3,7 @@ import Image from "next/image";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import type { Category } from "@/lib/types/api";
import { Skeleton } from "@/components/ui/skeleton";
type Props = {
categories: Category[] | undefined;
@@ -34,11 +35,11 @@ export default function CategoryGrid({
return (
<section className="bg-white rounded-2xl shadow-sm p-6">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<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) => (
<div key={i} className="space-y-2">
<div className="w-full h-36 bg-gray-200 rounded-lg animate-pulse" />
<div className="h-4 bg-gray-200 rounded w-full animate-pulse" />
<Skeleton className="w-full h-36 rounded-lg" />
<Skeleton className="h-4 w-full" />
</div>
))}
</div>

View File

@@ -5,6 +5,7 @@ import InfiniteScroll from "react-infinite-scroll-component";
import HeroCarousel from "./Carousel";
import CategoryGrid from "./CategoryGrid";
import CollectionSection from "./ProductGrid";
import { Skeleton } from "@/components/ui/skeleton";
import {
useCategories,
useCarousels,
@@ -29,7 +30,6 @@ export default function HomePage() {
isError: collectionsError,
} = useCollections();
// Prefetch favorites
useFavorites();
const loadMore = () => {
@@ -50,8 +50,12 @@ export default function HomePage() {
return (
<div className="px-2 md:px-4 lg:px-6 pt-4 pb-12 space-y-8 max-w-[1504px] mx-auto">
{!carouselsLoading && carouselItems.length > 0 && (
<HeroCarousel items={carouselItems} />
{carouselsLoading ? (
<section className=" bg-white rounded-2xl overflow-hidden">
<Skeleton className="w-full h-[200px] sm:h-[300px] md:h-[496px]" />
</section>
) : (
carouselItems.length > 0 && <HeroCarousel items={carouselItems} />
)}
<CategoryGrid
@@ -72,13 +76,16 @@ export default function HomePage() {
<div className="space-y-8">
{Array.from({ length: 3 }).map((_, i) => (
<section key={i} className="bg-white rounded-2xl shadow-sm p-6">
<div className="h-8 bg-gray-200 rounded w-48 mb-4 animate-pulse" />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{Array.from({ length: 5 }).map((_, j) => (
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-6 w-6 rounded-full" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4">
{Array.from({ length: 10 }).map((_, j) => (
<div key={j} className="space-y-2">
<div className="w-full h-[260px] bg-gray-200 rounded-xl animate-pulse" />
<div className="h-4 bg-gray-200 rounded w-3/4 animate-pulse" />
<div className="h-6 bg-gray-200 rounded w-1/2 animate-pulse" />
<Skeleton className="w-full h-[260px] rounded-xl" />
<Skeleton className="h-4 w-3/4 mx-2" />
<Skeleton className="h-6 w-1/2 mx-2" />
</div>
))}
</div>

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, 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;
@@ -166,7 +169,7 @@ export default function ProductCard({
{
onSuccess: (data) =>
toast.success(
data.wasAdded ? "Added to favorites" : "Removed from favorites"
data.wasAdded ? t("added_to_favorites") : t("removed_from_favorites")
),
onError: () => toast.error("Error. Try again"),
}
@@ -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;
}
@@ -197,18 +195,17 @@ export default function ProductCard({
productId: id,
quantity: localQuantity,
});
await refetchCart();
toast.success("Added to cart", {
description: `${name} has been added to your cart`,
toast.success(t("added_to_cart"), {
description: `${name} ${t("added_to_cart_description")}`,
});
} catch (error) {
console.error("Add to cart error:", error);
toast.error("Failed to add to cart");
toast.error(t("add_to_cart_failed"));
} finally {
setIsSyncing(false);
}
},
[id, name, localQuantity, availableStock, addToCartMutation, refetchCart]
[id, name, localQuantity, availableStock, addToCartMutation]
);
const handleQuantityChange = useCallback(
@@ -221,10 +218,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 +227,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,169 +253,200 @@ export default function ProductCard({
};
return (
<div
onClick={handleCardClick}
className="flex justify-center cursor-pointer"
>
<Card
className="relative gap-2 border-none shadow-none p-0 w-full overflow-hidden rounded-2xl"
style={{ height, maxWidth: width }}
<>
<div
onClick={handleCardClick}
className="flex justify-center cursor-pointer"
>
<div className="relative w-full h-[260px] group">
<Carousel
opts={{ align: "start", loop: true, watchDrag: false }}
setApi={setApi}
className="w-full h-full"
>
<CarouselContent className="h-[260px] ml-0">
{images.map((image, idx) => (
<CarouselItem key={idx} className="h-[260px] pl-0">
<div className="h-full flex items-center justify-center">
<img
src={image}
alt={`${name} - ${idx + 1}`}
className="max-w-full max-h-full object-contain"
draggable="false"
/>
</div>
</CarouselItem>
))}
</CarouselContent>
<Card
className="relative gap-2 border-none shadow-none p-0 w-full overflow-hidden rounded-2xl"
style={{ height, maxWidth: width }}
>
<div className="relative w-full h-[260px] group">
<Carousel
opts={{ align: "start", loop: true, watchDrag: false }}
setApi={setApi}
className="w-full h-full"
>
<CarouselContent className="h-[260px] ml-0">
{images.map((image, idx) => (
<CarouselItem key={idx} className="h-[260px] pl-0">
<div className="h-full flex items-center justify-center">
<img
src={image}
alt={`${name} - ${idx + 1}`}
className="max-w-full max-h-full object-contain"
draggable="false"
/>
</div>
</CarouselItem>
))}
</CarouselContent>
{hasMultipleImages && (
<>
<CarouselPrevious
data-carousel-control="true"
className="absolute left-2 opacity-0 group-hover:opacity-100 transition-opacity z-20"
onClick={(e) => handleNavClick(e, () => api?.scrollPrev())}
/>
<CarouselNext
data-carousel-control="true"
className="absolute right-2 opacity-0 group-hover:opacity-100 transition-opacity z-20"
onClick={(e) => handleNavClick(e, () => api?.scrollNext())}
/>
</>
)}
</Carousel>
{hasMultipleImages && (
<>
<CarouselPrevious
data-carousel-control="true"
className="absolute left-2 opacity-0 group-hover:opacity-100 transition-opacity z-20"
onClick={(e) => handleNavClick(e, () => api?.scrollPrev())}
/>
<CarouselNext
data-carousel-control="true"
className="absolute right-2 opacity-0 group-hover:opacity-100 transition-opacity z-20"
onClick={(e) => handleNavClick(e, () => api?.scrollNext())}
/>
</>
)}
</Carousel>
<button
onClick={handleFavorite}
disabled={isFavoriteToggling || isFavoriteLoading}
className="absolute top-3 right-3 z-10 rounded-full bg-white/80 p-2 hover:bg-white transition-all disabled:opacity-50"
>
{isFavoriteLoading ? (
<div className="w-5 h-5 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin" />
) : (
<Heart
className={`w-5 h-5 ${
isFavorite ? "text-red-500 fill-red-500" : "text-gray-700"
}`}
/>
)}
</button>
{hasMultipleImages && (
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 flex gap-1.5">
{images.map((_, idx) => (
<button
key={idx}
data-carousel-control="true"
onClick={(e) => handleNavClick(e, () => api?.scrollTo(idx))}
className={`h-1.5 rounded-full transition-all ${
idx === current ? "w-6 bg-white" : "w-1.5 bg-white/60"
<button
onClick={handleFavorite}
disabled={isFavoriteToggling || isFavoriteLoading}
className="absolute top-3 cursor-pointer right-3 z-10 rounded-full bg-white/80 p-2 hover:bg-white transition-all disabled:opacity-50"
>
{isFavoriteLoading ? (
<div className="w-5 h-5 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin" />
) : (
<Heart
className={`w-5 h-5 ${
isFavorite ? "text-[#005bff] fill-[#005bff]" : "text-gray-700"
}`}
/>
))}
</div>
)}
)}
</button>
{labels.length > 0 && (
<div className="absolute top-2 left-2 flex flex-col gap-1 z-10">
{labels.map((label, idx) => (
<Badge
key={idx}
className="text-white text-[10px] font-bold uppercase rounded-r-md"
style={{ backgroundColor: label.bg_color }}
>
{label.text}
{hasMultipleImages && (
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 flex gap-1.5">
{images.map((_, idx) => (
<button
key={idx}
data-carousel-control="true"
onClick={(e) => handleNavClick(e, () => api?.scrollTo(idx))}
className={`h-1.5 rounded-full cursor-pointer transition-all ${
idx === current ? "w-6 bg-white" : "w-1.5 bg-white/60"
}`}
/>
))}
</div>
)}
{labels.length > 0 && (
<div className="absolute top-2 left-2 flex flex-col gap-1 z-10">
{labels.map((label, idx) => (
<Badge
key={idx}
className="text-white text-[10px] font-bold uppercase rounded-r-md"
style={{ backgroundColor: label.bg_color }}
>
{label.text}
</Badge>
))}
</div>
)}
{isOutOfStock && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center z-10">
<Badge variant="secondary" className="text-sm font-bold">
{t("outOfStock")}
</Badge>
))}
</div>
)}
{isOutOfStock && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center z-10">
<Badge variant="secondary" className="text-sm font-bold">
Out of Stock
</Badge>
</div>
)}
</div>
<CardContent className="p-0 space-y-1">
<p
className="text-sm mx-2 font-medium"
style={{ color: price_color }}
>
{struct_price_text}
</p>
<p className="text-black text-sm font-semibold leading-normal truncate mx-2">
{name}
</p>
</CardContent>
{button && !isOutOfStock && (
<div className="px-1">
{!isInCart ? (
<Button
onClick={handleAddToCart}
disabled={isSyncing}
className="w-full rounded-lg gap-2 bg-[#005bff] hover:bg-[#0041c4]"
size="sm"
>
{isSyncing ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Adding...
</>
) : (
<>
<ShoppingCart className="h-4 w-4" />
{t("checkout")}
</>
)}
</Button>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={(e) => handleQuantityChange(e, -1)}
disabled={isSyncing || localQuantity <= 1}
className="rounded-lg h-9 w-9 shrink-0"
>
<Minus className="h-4 w-4" />
</Button>
<div className="flex-1 text-center font-semibold text-sm border rounded-lg h-9 flex items-center justify-center bg-white relative">
{localQuantity}
{isSyncing && (
<Loader2 className="h-3 w-3 animate-spin absolute -top-1 -right-1 text-blue-500" />
)}
</div>
<Button
variant="outline"
size="icon"
onClick={(e) => handleQuantityChange(e, 1)}
disabled={localQuantity >= availableStock || isSyncing}
className="rounded-lg h-9 w-9 shrink-0"
>
<Plus className="h-4 w-4 text-[#005bff]" />
</Button>
</div>
)}
</div>
)}
</Card>
</div>
<CardContent className="p-0 space-y-1">
<p
className="text-sm mx-2 font-medium"
style={{ color: price_color }}
>
{struct_price_text}
</p>
<p className="text-black text-sm font-semibold leading-normal truncate mx-2">
{name}
</p>
</CardContent>
{button && !isOutOfStock && (
<div className="px-1">
{!isInCart ? (
<Button
onClick={handleAddToCart}
disabled={isSyncing}
className="w-full rounded-lg cursor-pointer gap-2 bg-[#005bff] hover:bg-[#0041c4]"
size="sm"
>
{isSyncing ? (
<>
{t("adding")}
</>
) : (
<>
<ShoppingCart className="h-4 w-4" />
{t("add_to_cart")}
</>
)}
</Button>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={(e) => handleQuantityChange(e, -1)}
disabled={isSyncing || localQuantity <= 1}
className="rounded-lg cursor-pointer h-9 w-9 shrink-0"
>
<Minus className="h-4 w-4" />
</Button>
<div className="flex-1 text-center font-semibold text-sm border rounded-lg h-9 flex items-center justify-center bg-white relative">
{localQuantity}
</div>
<Button
variant="outline"
size="icon"
onClick={(e) => handleQuantityChange(e, 1)}
disabled={isSyncing}
className="rounded-lg cursor-pointer h-9 w-9 shrink-0"
>
<Plus className="h-4 w-4 text-[#005bff]" />
</Button>
</div>
)}
</div>
)}
</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 cursor-pointer"
>
{t("understood")}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
}

View File

@@ -2,8 +2,9 @@
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";
import { Skeleton } from "@/components/ui/skeleton";
type Props = {
collection: Collection;
@@ -22,24 +23,19 @@ export default function CollectionSection({ collection, locale }: Props) {
router.push(`/collections/${collection.slug}`);
};
// Hide section if no products
if (!isLoading && (!productsData?.data || productsData.data.length === 0)) {
return null;
}
if (isLoading) {
return (
<section className="bg-white rounded-2xl shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse" />
<div className="h-6 w-6 bg-gray-200 rounded-full animate-pulse" />
<Skeleton className="h-8 w-48" />
<Skeleton className="h-6 w-6 rounded-full" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{Array.from({ length: 5 }).map((_, i) => (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="w-full h-[260px] bg-gray-200 rounded-xl animate-pulse" />
<div className="h-4 bg-gray-200 rounded w-3/4 animate-pulse mx-2" />
<div className="h-6 bg-gray-200 rounded w-1/2 animate-pulse mx-2" />
<Skeleton className="w-full h-[260px] rounded-xl" />
<Skeleton className="h-4 w-3/4 mx-2" />
<Skeleton className="h-6 w-1/2 mx-2" />
</div>
))}
</div>
@@ -49,6 +45,11 @@ export default function CollectionSection({ collection, locale }: Props) {
if (isError) return null;
// Hide section if no products
if (!productsData?.data || productsData.data.length === 0) {
return null;
}
const displayProducts = productsData?.data.slice(0, 10) || [];
return (
@@ -99,4 +100,4 @@ export default function CollectionSection({ collection, locale }: Props) {
</div>
</section>
);
}
}

View File

@@ -139,7 +139,6 @@ export function useCollectionProductsInfinite(
},
getNextPageParam: (lastPage) => {
if (lastPage.pagination?.next_page_url) {
// Extract page number from URL or increment
const currentPage = lastPage.pagination.page || 1;
return currentPage + 1;
}

View File

@@ -1,32 +1,30 @@
import { Package } from "lucide-react"
import { Button } from "@/components/ui/button"
import Link from "next/link"
import { Button } from "@/components/ui/button";
import { ShoppingCart } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
interface EmptyOrdersProps {
locale?: string
message?: string
actionText?: string
actionHref?: string
}
export default function EmptyOrders({
locale = "ru",
message = "No orders yet",
actionText = "Start Shopping",
actionHref = "/",
}: EmptyOrdersProps) {
export default function EmptyOrders() {
const t=useTranslations();
const router=useRouter();
return (
<div className="min-h-[400px] flex flex-col items-center justify-center p-4">
<Package className="h-16 w-16 text-gray-300 mb-4" />
<h2 className="text-2xl font-semibold text-gray-600 mb-2">{message}</h2>
<p className="text-gray-500 mb-6 text-center max-w-sm">
{locale === "ru"
? "У вас еще нет заказов. Начните покупки прямо сейчас!"
: "You haven't placed any orders yet. Start shopping now!"}
</p>
<Link href={actionHref}>
<Button className="rounded-xl">{actionText}</Button>
</Link>
<div className="flex min-h-[60vh] items-center justify-center px-4">
<div className="w-full max-w-md rounded-2xl bg-linear-to-br from-blue-50 to-white p-8 text-center shadow-lg">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-blue-100">
<ShoppingCart className="h-10 w-10 text-blue-600" />
</div>
<h2 className="mb-2 text-2xl font-semibold text-gray-900">
{t("orders_empty")}
</h2>
<p className="mb-6 text-sm text-gray-500">
{t("orders_empty_message")}
</p>
<Button onClick={()=>router.push("/")} className="w-full rounded-lg cursor-pointer bg-blue-600 px-6 py-3 text-sm font-medium text-white transition hover:bg-blue-700 active:scale-95">
{t("start_shopping")}
</Button>
</div>
</div>
)
}
);
}

View File

@@ -24,11 +24,12 @@ 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";
import EmptyOrders from "./EmptyOrders";
import ErrorPage from "@/components/ErrorPage";
interface OrdersPageClientProps {
locale: string;
}
@@ -37,7 +38,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 +67,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,35 +162,67 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
if (isLoading) {
return (
<div className="container mx-auto p-4 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{t("my_orders")}</h1>
<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 Skeleton */}
<div className="mb-4 md:mb-6">
<div className="flex gap-2 mb-4">
<Skeleton className="h-10 w-32 rounded-md" />
<Skeleton className="h-10 w-32 rounded-md" />
</div>
</div>
{/* Order Cards Skeleton */}
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-32 rounded-lg" />
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i} className="overflow-hidden py-2 md:py-4 lg:py-6">
<div className="p-2 md:p-4 mx-2 md:mx-4 rounded-lg">
<div className="flex items-center justify-between">
{/* Left side - Order info */}
<div className="flex items-center gap-4 flex-1">
<Skeleton className="h-5 w-5 rounded" />
<div className="space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-24" />
</div>
</div>
{/* Right side - Status and price */}
<div className="flex items-center gap-4">
<div className="flex flex-col md:flex-row gap-2 items-end">
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-6 w-24" />
</div>
<Skeleton className="h-5 w-5 rounded" />
</div>
</div>
</div>
</Card>
))}
</div>
</div>
);
}
if (isError) {
return <ErrorPage />;
}
if (isError || !orders || orders.length === 0) {
return (
<div className="container mx-auto p-4 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{t("my_orders")}</h1>
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-2xl text-gray-400">{t("no_orders")}</p>
</div>
</div>
);
return <EmptyOrders />;
}
return (
<div className="container 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>
<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">
<TabsList className="mb-4 md:mb-6 w-full md:w-fit gap-2 p-0">
<TabsTrigger value="active" >
<TabsTrigger value="active">
{t("active_orders")} ({activeOrders.length})
</TabsTrigger>
<TabsTrigger value="completed">
@@ -270,6 +296,7 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
variant="outline"
onClick={() => setIsCancelDialogOpen(false)}
disabled={isCancellingOrder}
className="cursor-pointer"
>
{t("keep_order")}
</Button>
@@ -277,6 +304,7 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
variant="destructive"
onClick={confirmCancelOrder}
disabled={isCancellingOrder}
className="cursor-pointer"
>
{isCancellingOrder ? t("cancelling") : t("cancel_order")}
</Button>
@@ -336,14 +364,13 @@ function CompactOrderCard({
</div>
<div className="flex items-center gap-4">
<div className="flex flex-col md:flex-row gap-2 ">
{getStatusBadge(order.status)}
<div className="text-right">
<p className="font-bold text-lg text-green-600">
{total.toFixed(2)} TMT
</p>
</div>
<div className="flex flex-col md:flex-row gap-2 items-end">
{getStatusBadge(order.status)}
<div className="text-right">
<p className="font-bold text-lg text-green-600">
{total.toFixed(2)} TMT
</p>
</div>
</div>
{isExpanded ? (
<ChevronUp className="h-5 w-5 text-gray-400" />
@@ -359,7 +386,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">
@@ -370,7 +397,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" />
@@ -416,7 +443,7 @@ function CompactOrderCard({
key={index}
className="flex items-center gap-4 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
>
<div className="relative w-16 h-16 flex-shrink-0 rounded-md overflow-hidden bg-white border">
<div className="relative w-16 h-16 shrink-0 rounded-md overflow-hidden bg-white border">
<Image
src={
item.product.images_400x400 || item.product.thumbnail
@@ -466,7 +493,7 @@ function CompactOrderCard({
onCancel(order);
}}
disabled={isCancelling}
className="w-full"
className="w-full cursor-pointer"
>
{t("cancel_order")}
</Button>

View File

@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import type { Order, OrdersResponse, CreateOrderRequest } from "@/lib/types/api";
import type { Order, OrdersResponse } from "@/lib/types/api";
export function useOrders(options?: { page?: number; perPage?: number }) {
return useQuery<Order[]>({
@@ -13,7 +13,6 @@ export function useOrders(options?: { page?: number; perPage?: number }) {
},
});
// API response'dan data array'ini döndür
return response.data.data;
},
staleTime: 1000 * 60 * 5,
@@ -34,33 +33,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

@@ -44,7 +44,7 @@ export function ProductImageGallery({
);
return (
<div className="flex-1 max-w-2xl">
<div className="contents max-w-2xl">
<div className="relative">
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-white">
{images.length > 0 ? (
@@ -68,7 +68,7 @@ export function ProductImageGallery({
<button
key={index}
onClick={() => handleImageSelect(index)}
className={`relative w-16 h-16 flex-shrink-0 rounded overflow-hidden border-2 transition-all ${
className={`relative w-16 h-16 shrink-0 rounded cursor-pointer overflow-hidden border-2 transition-all ${
selectedImage === index
? "border-primary ring-2 ring-primary/20"
: "border-gray-200 hover:border-gray-300"

View File

@@ -8,6 +8,7 @@ interface ProductProperty {
}
interface ProductInfoCardProps {
name: string;
brandName?: string;
stock?: number;
barcode?: string;
@@ -20,6 +21,7 @@ interface ProductInfoCardProps {
}
export function ProductInfoCard({
name,
brandName,
stock,
barcode,
@@ -30,27 +32,10 @@ export function ProductInfoCard({
reviewsCount,
t,
}: ProductInfoCardProps) {
const renderStars = (rating: number) => {
return (
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`h-5 w-5 transition-all ${
star <= rating
? "fill-yellow-400 text-yellow-400"
: "text-gray-300"
}`}
/>
))}
</div>
);
};
return (
<div className="flex-1 space-y-6 bg-white">
<Card className="p-4 rounded-xl border-gray-200">
<h3 className="text-xl font-semibold mb-4">{t("about_product")}</h3>
<h3 className="text-xl font-semibold mb-4">{name}</h3>
<div className="space-y-3">
{brandName && (
<>
@@ -62,7 +47,7 @@ export function ProductInfoCard({
</>
)}
{stock !== undefined && (
{/* {stock !== undefined && (
<>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{t("stock")}</span>
@@ -94,7 +79,7 @@ export function ProductInfoCard({
</div>
<Separator />
</>
)}
)} */}
{colour && (
<>
@@ -126,7 +111,7 @@ export function ProductInfoCard({
</Card>
{description && (
<Card className="p-4 rounded-xl border-gray-200">
<Card className="p-4 rounded-xl border-gray-200 gap-2">
<h3 className="text-xl font-semibold mb-3">
{t("product_description")}
</h3>
@@ -138,4 +123,4 @@ export function ProductInfoCard({
)}
</div>
);
}
}

View File

@@ -12,6 +12,7 @@ import {
useUpdateCartItemQuantity,
useRemoveFromCart,
useCart,
cartEvents,
} from "@/features/cart/hooks/useCart";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
@@ -22,7 +23,10 @@ import { ProductReviewsSection } from "./ProductReviewsSection";
import { RelatedProductsSection } from "./RelatedProductsSection";
import { ReviewModal } from "./ReviewModal";
import { StockLimitModal } from "./StockLimitModal";
import {
useIsFavorite,
useToggleFavorite,
} from "@/features/favorites/hooks/useFavorites";
interface ProductDetailProps {
slug: string;
}
@@ -35,13 +39,19 @@ interface PendingUpdate {
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 [isInitialized, setIsInitialized] = useState(false);
const t = useTranslations();
@@ -52,6 +62,8 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
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,
@@ -59,9 +71,24 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
error,
refetch: refetchProduct,
} = useProductsBySlug(slug);
const { isFavorite, isLoading: isFavLoading } = useIsFavorite(
product?.id || 0
);
const cartOptions = useMemo(
() => ({
refetchOnMount: true,
refetchOnWindowFocus: true,
staleTime: 0,
}),
[]
);
const { mutate: toggleFavoriteMutation } = useToggleFavorite();
const {
data: cartData,
refetch: refetchCart,
isFetching: isCartFetching,
} = useCart(cartOptions);
const { data: cartData, refetch: refetchCart } = useCart();
const { data: relatedProducts } = useRelatedProducts(product?.id || 0, {
enabled: !!product?.id,
});
@@ -71,10 +98,14 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
const removeFromCartMutation = useRemoveFromCart();
const submitReviewMutation = useSubmitReview();
const cartItem = useMemo(
() => cartData?.data?.find((item: any) => item.product?.id === product?.id),
[cartData, product]
);
const cartItem = useMemo(() => {
const item = cartData?.data?.find(
(item: any) => item.product?.id === product?.id
);
return item;
}, [cartData, product, isInitialized]);
const isInCart = !!cartItem;
const availableStock = product?.stock || 0;
@@ -86,17 +117,45 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
[product]
);
// ✅ CORRECT - Use reviews from product data
const reviews = useMemo(() => product?.reviews_resources || [], [product]);
const averageRating = useMemo(
() => (product?.reviews?.rating ? parseFloat(product.reviews.rating) : 0),
() =>
product?.reviews?.rating ? Number.parseFloat(product.reviews.rating) : 0,
[product]
);
const transformedRelatedProducts = useMemo(() => {
if (!relatedProducts) return [];
return relatedProducts.map((p) => ({
id: p.id,
slug: p.slug,
name: p.name,
price_amount: p.price_amount,
old_price_amount: p.old_price_amount ?? undefined,
struct_price_text: `${p.price_amount} TMT`,
discount: null,
discount_text: null,
stock: p.stock,
media: p.media,
labels: [],
price_color: undefined,
}));
}, [relatedProducts]);
useEffect(() => {
if (!product?.id || isInitialized) return;
if (cartItem?.product_quantity) {
setLocalQuantity(cartItem.product_quantity);
const serverQuantity = cartItem.product_quantity;
setLocalQuantity(serverQuantity);
lastSyncedQuantityRef.current = serverQuantity;
}
setIsInitialized(true);
}, [product?.id, cartItem, isInitialized]);
useEffect(() => {
setLocalQuantity(cartItem?.product_quantity || 1);
}, [cartItem]);
const savePendingUpdate = useCallback(
@@ -152,6 +211,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
if (retryCount >= maxRetries) {
setSyncError(true);
setIsSyncing(false);
shouldSyncFromCartRef.current = true;
toast.error(t("error"), {
description: t("update_quantity_failed"),
});
@@ -199,11 +259,8 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
});
}
isRequestInFlightRef.current = false;
setIsSyncing(false);
retryCountRef.current = 0;
clearPendingUpdate();
await refetchCart();
if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current;
@@ -211,15 +268,15 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100);
}
} catch (error) {
console.error("Sync failed:", error);
isRequestInFlightRef.current = false;
if (retryCountRef.current >= 3) {
setLocalQuantity(cartItem?.product_quantity || 1);
clearPendingUpdate();
}
setLocalQuantity(cartItem?.product_quantity || 1);
toast.error(t("failed_to_update_quantity"), {
description: "Please try again",
});
retrySyncRef.current?.(quantity);
} finally {
isRequestInFlightRef.current = false;
setIsSyncing(false);
}
},
[
@@ -230,57 +287,22 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
removeFromCartMutation,
cartItem,
clearPendingUpdate,
refetchCart,
t,
]
);
syncToServerRef.current = syncToServer;
useEffect(() => {
if (!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
);
}
}
} 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);
}
if (localQuantity === (cartItem?.product_quantity || 1)) {
return;
}
savePendingUpdate(localQuantity);
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(localQuantity);
@@ -291,7 +313,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
clearTimeout(debounceTimerRef.current);
}
};
}, [localQuantity, isInCart, product?.id, cartItem, savePendingUpdate]);
}, [localQuantity, isInCart, product?.id, cartItem?.product_quantity]);
useEffect(() => {
return () => {
@@ -303,7 +325,13 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
const handleAddToCart = useCallback(async () => {
if (!product?.id) return;
if (localQuantity > availableStock) {
setShowStockModal(true);
return;
}
setIsSyncing(true);
shouldSyncFromCartRef.current = false;
try {
await addToCartMutation.mutateAsync({
@@ -311,7 +339,12 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
quantity: localQuantity,
});
await refetchCart();
lastSyncedQuantityRef.current = localQuantity;
setTimeout(() => {
shouldSyncFromCartRef.current = true;
}, 150);
setIsSyncing(false);
toast.success(t("added_to_cart"), {
@@ -320,28 +353,67 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
} catch (error) {
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, t]);
const handleQuantityIncrease = useCallback(() => {
if (localQuantity >= availableStock) {
setShowStockModal(true);
return;
}
setLocalQuantity((prev) => prev + 1);
setLocalQuantity((prev) => {
const newVal = prev + 1;
return newVal;
});
}, [localQuantity, availableStock]);
const handleQuantityDecrease = useCallback(() => {
if (localQuantity <= 0) return;
setLocalQuantity((prev) => prev - 1);
setLocalQuantity((prev) => {
const newVal = prev - 1;
return newVal;
});
}, [localQuantity]);
const handleToggleFavorite = useCallback(() => {
setIsFavorite(!isFavorite);
}, [isFavorite]);
const handleToggleFavorite = useCallback(
(e?: React.MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (!product?.id) {
toast.error(t("error"), {
description: "Product ID not found",
});
return;
}
toggleFavoriteMutation(
{
productId: product.id,
isFavorite,
},
{
onSuccess: (data) => {
toast.success(
data?.wasAdded
? t("added_to_favorites")
: t("removed_from_favorites")
);
},
onError: () => {
toast.error(t("error"), {
description: "Try again later",
});
},
}
);
},
[product?.id, isFavorite, toggleFavoriteMutation, t]
);
const handleSubmitReview = useCallback(
async (rating: number, text: string) => {
@@ -360,9 +432,8 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
source: "site",
});
// ✅ Refetch product to get updated reviews
await refetchProduct();
toast.success("Review submitted successfully!");
setShowReviewModal(false);
} catch (error) {
@@ -376,7 +447,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
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" />
@@ -400,7 +471,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
if (error || !product) {
return (
<div className="container mx-auto px-4 py-8 text-center">
<div className=" mx-auto px-4 py-8 text-center">
<h2 className="text-2xl font-bold text-red-600">
{t("product_not_found")}
</h2>
@@ -422,10 +493,11 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
/>
<ProductInfoCard
brandName={product.brand?.name}
name={product.name}
brandName={product.brand?.name ?? undefined}
stock={product.stock}
barcode={product.barcode}
colour={product.colour}
colour={product.colour ?? undefined}
properties={product.properties}
description={product.description}
averageRating={averageRating}
@@ -435,7 +507,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
<ProductPurchaseCard
price={product.price_amount}
oldPrice={product.old_price_amount}
oldPrice={product.old_price_amount ?? undefined}
isInCart={isInCart}
localQuantity={localQuantity}
availableStock={availableStock}
@@ -459,7 +531,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
onWriteReview={() => setShowReviewModal(true)}
/>
<RelatedProductsSection products={relatedProducts || []} />
<RelatedProductsSection products={transformedRelatedProducts} />
</div>
<StockLimitModal
@@ -478,4 +550,4 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
/>
</>
);
}
}

View File

@@ -1,5 +1,5 @@
import Link from "next/link";
import { Minus, Plus, Heart, ShoppingCart, Store, Loader2 } from "lucide-react";
import { Minus, Plus, Heart, ShoppingCart, Store } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -45,9 +45,7 @@ export function ProductPurchaseCard({
<div className="flex justify-between items-start mb-6">
<span className="text-lg text-gray-500">{t("price")}:</span>
<div className="flex flex-col items-end">
<span className="text-3xl font-bold text-primary">
{price} TMT
</span>
<span className="text-3xl font-bold text-primary">{price} TMT</span>
{oldPrice && parseFloat(oldPrice) > 0 && (
<span className="text-lg text-gray-400 line-through">
{oldPrice} TMT
@@ -62,7 +60,7 @@ export function ProductPurchaseCard({
<Link href="/cart">
<Button
size="lg"
className="w-full rounded-lg text-lg font-bold bg-green-600 hover:bg-green-700 mb-4"
className="w-full rounded-lg cursor-pointer text-lg font-bold bg-green-600 hover:bg-green-700 mb-4"
>
<ShoppingCart className="mr-2 h-5 w-5" />
{t("go_to_cart")}
@@ -75,7 +73,7 @@ export function ProductPurchaseCard({
size="icon"
onClick={onQuantityDecrease}
disabled={isSyncing}
className={`rounded-lg h-12 w-12 ${
className={`rounded-lg cursor-pointer h-12 w-12 ${
isSyncing ? "opacity-70" : ""
}`}
>
@@ -94,18 +92,15 @@ export function ProductPurchaseCard({
variant="outline"
size="icon"
onClick={onQuantityIncrease}
disabled={localQuantity >= availableStock || isSyncing}
className={`rounded-lg h-12 w-12 ${
disabled={isSyncing}
className={`rounded-lg cursor-pointer h-12 w-12 ${
isSyncing ? "opacity-70" : ""
} ${
localQuantity >= availableStock
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<Plus className="h-5 w-5" />
</Button>
<Button
variant="outline"
size="icon"
@@ -127,24 +122,43 @@ export function ProductPurchaseCard({
</div>
</>
) : (
<Button
size="lg"
onClick={onAddToCart}
disabled={isSyncing || productStock === 0}
className="w-full rounded-lg text-lg font-bold bg-[#005bff] hover:bg-[#0041c4] cursor-pointer"
>
{isSyncing ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t("adding")}
</>
) : (
<>
<ShoppingCart className="mr-2 h-5 w-5" />
{productStock === 0 ? t("out_of_stock") : t("add_to_cart")}
</>
)}
</Button>
<div className="flex items-center gap-2">
<Button
size="lg"
onClick={onAddToCart}
disabled={isSyncing || productStock === 0}
className="flex-1 rounded-lg text-lg font-bold bg-[#005bff] hover:bg-[#0041c4] cursor-pointer"
>
{isSyncing ? (
<>{t("adding")}</>
) : (
<>
<ShoppingCart className="mr-2 h-5 w-5" />
{productStock === 0 ? t("out_of_stock") : t("add_to_cart")}
</>
)}
</Button>
<Button
variant="outline"
size="icon"
onClick={onToggleFavorite}
className={`rounded-lg h-12 w-12 transition-all border cursor-pointer ${
isFavorite
? "bg-[#F0F8FF] border-blue-300 hover:bg-blue-100"
: "hover:bg-gray-50"
}`}
>
<Heart
className={`h-6! w-6! transition-all ${
isFavorite
? "fill-[#005bff] text-[#005bff]"
: "text-[#005bff]"
}`}
/>
</Button>
</div>
)}
</div>
</Card>
@@ -162,11 +176,15 @@ export function ProductPurchaseCard({
<h4 className="text-lg font-bold">{channelName}</h4>
</div>
</div>
<Button variant="outline" size="lg" className="w-full rounded-lg">
<Button
variant="outline"
size="lg"
className="w-full cursor-pointer rounded-lg"
>
{t("write_to_store")}
</Button>
</Card>
)}
</div>
);
}
}

View File

@@ -3,6 +3,7 @@ import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "next-intl";
interface Review {
id: number;
@@ -41,21 +42,23 @@ export function ProductReviewsSection({
);
};
const t= useTranslations();
return (
<Card className="p-6 rounded-xl">
<div className="flex justify-between items-center mb-6">
<div className="flex justify-between items-center ">
<div>
<h3 className="text-2xl font-bold">Customer Reviews</h3>
<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">
<Button onClick={onWriteReview} className="rounded-lg cursor-pointer bg-[#005bff] hover:bg-[#0041c4]">
<Send className="mr-2 h-4 w-4" />
Write Review
{t("write_review")}
</Button>
</div>
@@ -83,7 +86,7 @@ export function ProductReviewsSection({
</div>
) : (
<div className="text-center py-8 text-gray-500">
No reviews yet. Be the first to review this product!
{t("no_reviews")}
</div>
)}
</Card>

View File

@@ -1,5 +1,5 @@
import ProductCard from "@/features/home/components/ProductCard";
import {useTranslations} from "next-intl";
interface RelatedProduct {
id: number;
slug: string;
@@ -30,14 +30,14 @@ interface RelatedProductsSectionProps {
export function RelatedProductsSection({
products,
}: RelatedProductsSectionProps) {
const t = useTranslations();
if (!products || products.length === 0) return null;
return (
<div className="bg-white rounded-lg p-6">
<h2 className="text-2xl font-bold mb-6">Related Products</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<h2 className="text-2xl font-bold mb-6">{t("related_products")}</h2>
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{products.slice(0, 4).map((product) => {
// Extract image URLs from media
const images =
product.media?.map(
(m) =>

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Star, Send, Loader2 } from "lucide-react";
import { Star, Send } from "lucide-react";
import {
Dialog,
DialogContent,
@@ -9,6 +9,7 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "next-intl";
interface ReviewModalProps {
open: boolean;
@@ -27,6 +28,8 @@ export function ReviewModal({
const [text, setText] = useState("");
const [hoveredStar, setHoveredStar] = useState(0);
const t = useTranslations();
const handleClose = () => {
onOpenChange(false);
setRating(0);
@@ -63,29 +66,29 @@ export function ReviewModal({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-xl">Write a Review</DialogTitle>
<DialogTitle className="text-xl">{t("write_review")}</DialogTitle>
<DialogDescription>
Share your experience with this product
{t("share_experience")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-4">
<div>
<label className="block text-sm font-medium mb-2">Rating</label>
<label className="block text-sm font-medium mb-2">{t("rating")}</label>
{renderStars()}
</div>
<div>
<label className="block text-sm font-medium mb-2">
Your Review
{t("your_review")}
</label>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Write your review here..."
placeholder={t("write_review")}
className="min-h-[120px] resize-none"
maxLength={500}
/>
<p className="text-xs text-gray-500 mt-1">
{text.length}/500 characters
{text.length}/500 {t("characters")}
</p>
</div>
</div>
@@ -93,24 +96,24 @@ export function ReviewModal({
<Button
variant="outline"
onClick={handleClose}
className="flex-1 rounded-lg"
className="flex-1 rounded-lg cursor-pointer"
>
Cancel
{t("cancel")}
</Button>
<Button
onClick={handleSubmit}
disabled={rating === 0 || !text.trim() || isSubmitting}
className="flex-1 rounded-lg"
className="flex-1 rounded-lg cursor-pointer bg-[#005bff] hover:bg-[#0041c4]"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Submitting...
{t("submitting")}
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
Submit Review
{t("submit_review")}
</>
)}
</Button>

View File

@@ -45,7 +45,7 @@ export function StockLimitModal({
<div className="flex justify-center mt-4">
<Button
onClick={() => onOpenChange(false)}
className="w-full rounded-lg"
className="w-full rounded-lg cursor-pointer"
>
{t("understood")}
</Button>

View File

@@ -207,7 +207,7 @@ export function useSubmitReview() {
},
(variables) => [
reviewKeys.byProduct(variables.productId),
productKeys.bySlug(""), // Invalidates all slug queries
productKeys.bySlug(""),
reviewKeys.all,
]
);

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";
@@ -37,7 +38,6 @@ export default function ClientProfilePage(props: ProfilePageProps) {
useEffect(() => {
if (user && !isEditing) {
console.log("[Profile] User data loaded:", user);
setFormData({
name: user.first_name || "",
last_name: user.last_name || "",
@@ -46,9 +46,9 @@ export default function ClientProfilePage(props: ProfilePageProps) {
});
}
}, [user, isEditing]);
const { mutate: logout, isPending: isLoggingOut } = useLogout();
const handleLogout = useCallback(() => {
clearAuthToken();
logout();
window.location.href = "/";
}, []);
@@ -78,7 +78,7 @@ export default function ClientProfilePage(props: ProfilePageProps) {
const handleSave = useCallback(async () => {
if (!formData.name.trim()) {
toast.error(t("name_required") || "Name is required");
toast.error(t("requiredField") || "Name is required");
return;
}
@@ -89,7 +89,6 @@ export default function ClientProfilePage(props: ProfilePageProps) {
address: formData.address.trim(),
};
console.log("[Profile] Saving data:", apiData);
try {
await updateProfile.mutateAsync(apiData);
@@ -117,7 +116,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" />
@@ -159,7 +158,7 @@ export default function ClientProfilePage(props: ProfilePageProps) {
</p>
<Button
onClick={() => window.location.reload()}
className="w-full sm:w-auto"
className="w-full sm:w-auto cursor-pointer"
>
{t("try_again")}
</Button>
@@ -171,12 +170,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">
@@ -185,7 +184,7 @@ export default function ClientProfilePage(props: ProfilePageProps) {
: t("view_your_information")}
</p>
</div>
<div className="flex-shrink-0 w-12 h-12 sm:w-14 sm:h-14 bg-blue-600 rounded-full flex items-center justify-center shadow-sm">
<div className="shrink-0 w-12 h-12 sm:w-14 sm:h-14 bg-blue-600 rounded-full flex items-center justify-center shadow-sm">
<User className="h-6 w-6 sm:h-7 sm:w-7 text-white" />
</div>
</div>
@@ -208,7 +207,7 @@ export default function ClientProfilePage(props: ProfilePageProps) {
onClick={handleEdit}
variant="outline"
size="sm"
className="self-start sm:self-center border-gray-300 hover:bg-gray-50 text-gray-700 h-9"
className="self-start sm:self-center cursor-pointer border-gray-300 hover:bg-gray-50 text-gray-700 h-9"
>
<Edit2 className="h-3.5 w-3.5 sm:h-4 sm:w-4 mr-1.5 sm:mr-2" />
<span className="text-sm">{t("edit")}</span>
@@ -271,58 +270,56 @@ export default function ClientProfilePage(props: ProfilePageProps) {
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-5">
{/* Phone Field */}
<div className="space-y-2">
<Label
htmlFor="phone"
className="text-sm font-medium text-gray-700 flex items-center gap-1.5"
>
<Phone className="h-3.5 w-3.5 text-gray-400" />
{t("phone_number")}
</Label>
<Input
id="phone"
value={formData.phone_number}
onChange={(e) =>
handleInputChange("phone_number", e.target.value)
}
disabled={!isEditing}
className={`h-10 sm:h-11 text-sm sm:text-base ${
isEditing
? "border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
: "bg-gray-50 border-gray-200 text-gray-700"
}`}
placeholder={t("enter_phone_number")}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-5">
{/* Phone Field */}
<div className="space-y-2">
<Label
htmlFor="phone"
className="text-sm font-medium text-gray-700 flex items-center gap-1.5"
>
<Phone className="h-3.5 w-3.5 text-gray-400" />
{t("phone_number")}
</Label>
<Input
id="phone"
value={formData.phone_number}
onChange={(e) =>
handleInputChange("phone_number", e.target.value)
}
disabled={!isEditing}
className={`h-10 sm:h-11 text-sm sm:text-base ${
isEditing
? "border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
: "bg-gray-50 border-gray-200 text-gray-700"
}`}
placeholder={t("enter_phone_number")}
/>
</div>
{/* Address Field */}
<div className="space-y-2">
<Label
htmlFor="address"
className="text-sm font-medium text-gray-700 flex items-center gap-1.5"
>
<MapPin className="h-3.5 w-3.5 text-gray-400" />
{t("address")}
</Label>
<Input
id="address"
value={formData.address}
onChange={(e) =>
handleInputChange("address", e.target.value)
}
disabled={!isEditing}
className={`h-10 sm:h-11 text-sm sm:text-base ${
isEditing
? "border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
: "bg-gray-50 border-gray-200 text-gray-700"
}`}
placeholder={t("enter_address")}
/>
</div>
</div>
{/* Address Field */}
<div className="space-y-2">
<Label
htmlFor="address"
className="text-sm font-medium text-gray-700 flex items-center gap-1.5"
>
<MapPin className="h-3.5 w-3.5 text-gray-400" />
{t("address")}
</Label>
<Input
id="address"
value={formData.address}
onChange={(e) =>
handleInputChange("address", e.target.value)
}
disabled={!isEditing}
className={`h-10 sm:h-11 text-sm sm:text-base ${
isEditing
? "border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
: "bg-gray-50 border-gray-200 text-gray-700"
}`}
placeholder={t("enter_address")}
/>
</div>
</div>
{/* Action Buttons - Edit Mode */}
{isEditing && (
@@ -330,7 +327,7 @@ export default function ClientProfilePage(props: ProfilePageProps) {
<Button
onClick={handleSave}
disabled={updateProfile.isPending}
className="w-full sm:flex-1 bg-blue-600 hover:bg-blue-700 h-10 sm:h-11 text-sm sm:text-base font-medium shadow-sm"
className="w-full sm:flex-1 cursor-pointer bg-blue-600 hover:bg-blue-700 h-10 sm:h-11 text-sm sm:text-base font-medium shadow-sm"
>
<Save className="h-4 w-4 mr-2" />
{updateProfile.isPending
@@ -341,7 +338,7 @@ export default function ClientProfilePage(props: ProfilePageProps) {
onClick={handleCancel}
variant="outline"
disabled={updateProfile.isPending}
className="w-full sm:flex-1 h-10 sm:h-11 text-sm sm:text-base font-medium border-gray-300 hover:bg-gray-50"
className="w-full sm:flex-1 cursor-pointer h-10 sm:h-11 text-sm sm:text-base font-medium border-gray-300 hover:bg-gray-50"
>
<X className="h-4 w-4 mr-2" />
{t("cancel")}
@@ -359,7 +356,7 @@ export default function ClientProfilePage(props: ProfilePageProps) {
onClick={handleLogout}
variant="destructive"
size="lg"
className="w-full sm:w-auto sm:min-w-[280px] flex items-center justify-center gap-2 h-11 text-sm sm:text-base font-medium shadow-sm"
className="w-full cursor-pointer sm:w-auto sm:min-w-[280px] flex items-center justify-center gap-2 h-11 text-sm sm:text-base font-medium shadow-sm"
>
<LogOut className="h-4 w-4 sm:h-5 sm:w-5" />
{t("common.logout")}

View File

@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import { userStore } from "../userStore";
// import { userStore } from "../userStore";
import type { ProfileResponse, UpdateProfileRequest, UpdateProfileResponse } from "@/lib/types/api";
export const useUserProfile = () => {
@@ -11,7 +11,7 @@ export const useUserProfile = () => {
const userData = response.data.data;
// Store'a kaydet
userStore.setUser(userData);
// userStore.setUser(userData);
return userData;
},
@@ -29,7 +29,7 @@ export const useUpdateProfile = () => {
return response.data.data;
},
onSuccess: (data) => {
userStore.setUser(data);
// userStore.setUser(data);
queryClient.setQueryData(["user-profile"], data);
queryClient.invalidateQueries({ queryKey: ["user-profile"] });
},

View File

@@ -1,30 +0,0 @@
import type { UserProfile } from "@/lib/types/api";
// In-memory store (session-based, no persistence)
class UserStore {
private user: UserProfile | null = null;
setUser(user: UserProfile | null) {
this.user = user;
}
getUser(): UserProfile | null {
return this.user;
}
clearUser() {
this.user = null;
}
getOrderData(): { customer_name: string; customer_phone: string, customer_last_name: string } | null {
if (!this.user) return null;
return {
customer_name: this.user.first_name,
customer_last_name: this.user.last_name,
customer_phone: this.user.phone_number,
};
}
}
export const userStore = new UserStore();

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

@@ -33,9 +33,6 @@
"address_search": "Поиск адреса",
"address": "Адрес",
"first_name": "Имя",
"building": "Дом",
"floor": "Этаж",
"apartment": "Кв",
"save": "Сохранить",
"enter_phone": "Введите свой номер телефона",
"code_will_be_sent": "Мы вышлем вам код",
@@ -102,6 +99,8 @@
"empty_favorites": "У вас пока нет избранных товаров",
"removed_from_favorites": "Товар удален из избранного",
"added_to_cart": "Товар добавлен в корзину",
"failed_to_update_quantity": "Количество не удалось обновить",
"removed_from_cart": "Товар удален из корзины",
"error": "Произошла ошибка",
"out_of_stock": "Нет в наличии",
"personal_info": "Личная информация",
@@ -163,5 +162,35 @@
"enter_address": "Введите адрес",
"save_changes": "Сохранить изменения",
"saving": "Сохранение...",
"cancel": "Отменить"
"cancel": "Отменить",
"write_review": "Написать отзыв",
"no_reviews": "Отзывов пока нет, стать первым, кто оставил отзыв!",
"customer_reviews": "Отзывы",
"share_experience": "Поделитесь опытом с этим товаром",
"rating": "Рейтинг",
"your_review": "Ваш отзыв",
"submit": "Отправить",
"submitting": "Отправляется...",
"submit_review": "Отправить отзыв",
"characters": "символы",
"related_products": "Связанные товары",
"cart_empty_message": "Вы пока не добавили товары в корзину. Начните поиск и добавьте любимые товары в корзину.",
"start_shopping": "Начните поиск",
"favorites_empty": "У вас пока нет избранных товаров",
"favorites_empty_message": "Добавьте любимые товары в избранное",
"orders_empty": "У вас пока нет заказов",
"orders_empty_message": "Начните делать заказы",
"product": "Продукт",
"collection_not_found": "Коллекция не найдена",
"added_to_favorites": "Товар добавлен в избранное",
"submit_success": "Отзыв отправлен",
"submit_error": "Произошла ошибка",
"title": "Открыть магазин",
"enter_email": "Введите email",
"uploadPatent": "Загрузить патент",
"outOfStock": "Нет в наличии",
"requiredField": "Обязательное поле",
"fileRequired": "Файл загрузить"
}

View File

@@ -33,9 +33,6 @@
"address_search": "Adres gözleg",
"address": "Adres",
"first_name": "Ady",
"building": "Jaý",
"floor": "Gat",
"apartment": "Otag",
"save": "Ýatda sakla",
"enter_phone": "Telefon belgisini giriziň",
"code_will_be_sent": "Biz size kod ugradarys",
@@ -78,7 +75,8 @@
"no": "Ýok",
"yes": "Hawa",
"cart_empty": "Siziň söwda sebediňiz boş",
"add_to_cart": "Söwda sebedine goşmak",
"add_to_cart": "Sebede goş",
"go_to_cart": "Sebede geçmek",
"products": "Azyk harytlary",
"become_seller": "Satyjy bolmak",
@@ -102,6 +100,8 @@
"empty_favorites": "Siziň saýlanan harytlaryňyz ýok",
"removed_from_favorites": "Haryt saýlanlardan aýryldy",
"added_to_cart": "Haryt sebede goşuldy",
"failed_to_update_quantity": "Mukdar täzelenip bolmady",
"removed_from_cart": "Haryt sebetden aýryldy",
"error": "Ýalňyşlyk ýüze çykdy",
"out_of_stock": "Haryt ýok",
"personal_info": "Şahsy maglumat",
@@ -133,7 +133,7 @@
"product_description": "Haryt barada düşündiriş",
"adding": "Goşulýar...",
"added_to_cart_description": "sebede goşuldy",
"add_to_cart_failed": "Haryt sebede goşup bolmady",
"add_to_cart_failed": "Haryt sebede goşulmady",
"cart_updated": "Sebet täzelendi",
"update_quantity_failed": "Mukdar täzelenip bolmady",
"logging_out": "Çykylýar...",
@@ -163,6 +163,33 @@
"enter_address": "Adres giriziň",
"save_changes": "Ýatda sakla",
"saving": "Ýatda saklýar...",
"cancel": "Goýbolsun"
"cancel": "Goýbolsun",
"write_review": "Teswir ýaz",
"no_reviews": "Entek teswir ýok, ilkinji teswiri siz ýazyň!",
"customer_reviews": "Teswirler",
"share_experience": "Bu haryt barada öz teswiriňizi ýazyň",
"rating": "Reýting",
"your_review": "Teswiriňiz",
"submit": "Ugratmak",
"submitting": "Ugradylýar...",
"submit_review": "Teswiri ugrat",
"characters": "simbol",
"related_products": "Meňzeş harytlar",
"cart_empty_message": "Entek sebediňize haryt goşmadyňyz. Söwda etmäge başlaň!!!",
"start_shopping": "Söwda etmäge başla!",
"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ň!",
"product": "haryt",
"collection_not_found": "Kolleksiýa tapylmady",
"added_to_favorites": "Haryt saýlananlara goşuldy",
"submit_success": "Üstünlikli ugradyldy",
"submit_error": "Ýalňyşlyk ýüze çykdy",
"title": "Magazin aç",
"enter_email": "Poçtaňyzy ýazyň",
"uploadPatent": "Patent goş",
"outOfStock": "Ammarda ýok",
"requiredField": "Zerur maglumat",
"fileRequired": "Fayl goş"
}

View File

@@ -1,61 +1,25 @@
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";
console.log("API URL:", this.baseUrl);
this.client = axios.create({
baseURL: `${this.baseUrl}/api/v1`,
@@ -64,64 +28,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 = "tm";
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 +93,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 +127,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,10 +1,14 @@
// 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 {
phone_number: string;
phone_number: number;
password?: string;
}
@@ -15,8 +19,8 @@ interface RegisterData {
}
interface VerifyTokenData {
phone_number: string;
code: string;
phone_number: number;
code: number;
}
interface AuthResponse {
@@ -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);
queryClient.invalidateQueries({ queryKey: ["auth-status"] });
}
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);
queryClient.invalidateQueries({ queryKey: ["auth-status"] });
}
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,22 +220,17 @@ 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);
console.warn("[Logout] Server call failed, clearing local state anyway");
}
},
onSuccess: () => {
clearAuthToken();
TokenStorage.clearTokens();
queryClient.clear();
if (typeof window !== "undefined") {
window.location.href = "/login";
}
},
onError: (error) => {
console.error("Logout hatası:", error);
clearAuthToken();
onError: () => {
TokenStorage.clearTokens();
queryClient.clear();
},
});

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

56
lib/tokenStorage.ts Normal file
View File

@@ -0,0 +1,56 @@
// 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);
}
static setGuestToken(token: string): void {
if (!this.isClient) return;
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

@@ -12,6 +12,22 @@ export interface ProductMedia {
}
export interface Carousel {
title: string
image: string
url?: string | null
thumbnail: string;
link: string;
}
export interface Review {
id: number;
rating: number;
title: string;
created_at: string;
}
export type DeliveryType = "SELECTED_DELIVERY" | "PICK_UP";
export interface PaymentType {

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

@@ -5,14 +5,15 @@ const withNextIntl = createNextIntlPlugin("./i18n/i18n.ts")
const nextConfig: NextConfig = {
typescript: {
ignoreBuildErrors: true,
ignoreBuildErrors: false,
},
images: {
unoptimized: true,
remotePatterns: [
{
protocol: "https",
protocol: "http",
hostname: "shop.post.tm",
// port: "8080",
},
],
},

2245
package-lock.json generated

File diff suppressed because it is too large Load Diff

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB