Compare commits

..

20 Commits

Author SHA1 Message Date
@jcarymuhammedow
2ab9eab656 Merge branch 'main' of https://git.webulgam.com/nurmuhammet/postshop-frontend 2026-03-02 17:46:45 +05:00
@jcarymuhammedow
c13a4655bf added shipping method 2026-03-02 17:46:18 +05:00
Mekan1206
db7889fb7a update again 2026-02-14 22:54:03 +05:00
Mekan1206
1b378ccf79 fix critical package 2026-02-14 22:43:48 +05:00
@jcarymuhammedow
bf5980e3b3 fixed order, image carousel 2026-02-05 19:01:57 +05:00
@jcarymuhammedow
b546deeac0 added zoom function, fixed cart quantitu bug 2026-02-05 16:38:39 +05:00
a1b766fb3b change preview to start 2026-02-04 19:37:11 +05:00
a51a84409f add .env.example 2026-02-04 19:36:24 +05:00
bcd29eb03e Capitalize filename 2026-02-04 19:29:14 +05:00
@jcarymuhammedow
3e6ae5494c npm audit fixed 2026-02-04 18:49:23 +05:00
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
87 changed files with 5821 additions and 3046 deletions

6
.env.example Normal file
View File

@@ -0,0 +1,6 @@
# API
NEXT_PUBLIC_API_URL=http://shop.post.tm:8080
NEXT_PUBLIC_API_TOKEN=hello-bad-mf-s
# Environment
NODE_ENV=development

2
.gitignore vendored
View File

@@ -31,7 +31,7 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env
# vercel # vercel
.vercel .vercel

View File

@@ -1,36 +1,15 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash ```bash
cp .env.example .env
# Install packages
npm install
# Development
npm run dev npm run dev
# or
yarn dev # Production
# or npm run build
pnpm dev
# or # PM2
bun dev pm2 start "npm run start" --name postshop-frontend
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -2,10 +2,10 @@
2. Harytlar kem kas bolanu ucin home page doly gorkezenok 2. Harytlar kem kas bolanu ucin home page doly gorkezenok
3. Filter nahili isleyar
4. Order nadip otmen etmeli. 4. Order nadip otmen etmeli.
5. Review feed back yazylyan yer bamy bolmalymy 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,51 +3,55 @@ import { useState, useEffect, useMemo } from "react";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import CartItemCard from "../../../features/cart/components/CartItemCard"; import CartItemCard from "../../../features/cart/components/CartItemCard";
import CartItemSkeleton from "../../../features/cart/components/CartItemSkeleton";
import OrderSummary from "../../../features/cart/components/OrderSummary"; import OrderSummary from "../../../features/cart/components/OrderSummary";
import OrderSummarySkeleton from "../../../features/cart/components/OrderSummarySkeleton";
import { import {
useCart, useCart,
useCreateOrder, useCreateOrder,
useRegions, useRegions,
usePaymentTypes, usePaymentTypes,
useOrderDeliveries,
} from "@/lib/hooks"; } from "@/lib/hooks";
import { userStore } from "@/features/profile/userStore";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { DeliveryType, PaymentType } from "@/lib/types/api"; import type { PaymentType, OrderDelivery } from "@/lib/types/api";
import EmptyCart from "@/features/cart/components/EmptyCart";
import ErrorPage from "@/components/ErrorPage";
export default function CartPage() { export default function CartPage() {
const [isClient, setIsClient] = useState(false); const [isClient, setIsClient] = useState(false);
const [paymentType, setPaymentType] = useState<PaymentType | null>(null); const [paymentType, setPaymentType] = useState<PaymentType | null>(null);
const [deliveryType, setDeliveryType] = const [selectedOrderDelivery, setSelectedOrderDelivery] = useState<OrderDelivery | null>(null);
useState<DeliveryType>("SELECTED_DELIVERY");
const [selectedRegion, setSelectedRegion] = useState<string>(""); const [selectedRegion, setSelectedRegion] = useState<string>("");
const [selectedProvince, setSelectedProvince] = useState<number | null>(null); const [selectedProvince, setSelectedProvince] = useState<number | null>(null);
const [note, setNote] = useState<string>(""); const [notes, setNote] = useState<string>("");
const [phone, setPhone] = useState<string>(""); const [phone, setPhone] = useState<string>("+993 ");
const [name, setName] = useState<string>(""); const [name, setName] = useState<string>("");
const [lastName, setLastName] = useState<string>(""); const [lastName, setLastName] = useState<string>("");
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
const { data: cartResponse, isLoading, isError } = useCart(); const { data: cartResponse, isLoading: cartLoading, isError } = useCart();
const { data: provinces = [] } = useRegions(); const { data: provinces = [], isLoading: provincesLoading } = useRegions();
const { data: paymentTypes = [] } = usePaymentTypes(); const { data: paymentTypes = [], isLoading: paymentTypesLoading } =
usePaymentTypes();
const { data: orderDeliveries = [], isLoading: deliveriesLoading } = useOrderDeliveries();
const { mutate: createOrder, isPending: isCreatingOrder } = useCreateOrder(); const { mutate: createOrder, isPending: isCreatingOrder } = useCreateOrder();
const cartItems = cartResponse?.data || []; const cartItems = cartResponse?.data || [];
const isLoading = cartLoading || provincesLoading || paymentTypesLoading || deliveriesLoading;
useEffect(() => { useEffect(() => {
setIsClient(true); 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 handleRegionChange = (region: string) => {
setSelectedRegion(region);
setSelectedProvince(null);
setSelectedOrderDelivery(null);
};
const regionGroups = useMemo(() => { const regionGroups = useMemo(() => {
return provinces.reduce((acc, province) => { return provinces.reduce((acc, province) => {
if (!acc[province.region]) { if (!acc[province.region]) {
@@ -80,80 +84,98 @@ export default function CartPage() {
}, [cartItems]); }, [cartItems]);
const totalAmount = useMemo(() => { const totalAmount = useMemo(() => {
return cartItems.reduce((sum, item) => { const productsTotal = cartItems.reduce((sum, item) => {
const price = parseFloat(item.product.price_amount || "0"); const price = parseFloat(item.product.price_amount || "0");
return sum + price * item.product_quantity; return sum + price * item.product_quantity;
}, 0); }, 0);
return productsTotal;
}, [cartItems]); }, [cartItems]);
const handleDeliveryTypeChange = (type: DeliveryType) => { const finalTotal = useMemo(() => {
setDeliveryType(type); const shippingPrice = selectedOrderDelivery?.price || 0;
setSelectedProvince(null); return totalAmount + shippingPrice;
}, [totalAmount, selectedOrderDelivery]);
const formatPhoneForBackend = (phoneNumber: string): string => {
return phoneNumber.replace(/^\+993\s*/, "").replace(/\s+/g, "");
}; };
const handleCompleteOrder = () => { const handleCompleteOrder = () => {
if (!selectedRegion || !selectedProvince || !paymentType || !phone || !name) { if (!selectedRegion || !selectedProvince || !paymentType || !phone || !name || !selectedOrderDelivery) {
console.warn("Missing required fields for order"); console.warn("Missing required fields for order");
return; return;
} }
const selectedProvinceData = provinces.find( const phoneDigits = formatPhoneForBackend(phone);
(p) => p.id === selectedProvince if (phoneDigits.length !== 8) {
); console.warn("Phone number must be exactly 8 digits");
if (!selectedProvinceData) return; return;
}
const orderData = userStore.getOrderData(); const selectedProvinceData = provinces.find((p) => p.id === selectedProvince);
if (!orderData) { if (!selectedProvinceData) return;
console.error("User data not found");
router.push("/login");
return;
}
createOrder( createOrder(
{ {
customer_name: name, customer_name: `${name} ${lastName}`.trim(),
customer_phone: phone, customer_phone: phoneDigits,
customer_address: selectedProvinceData.name, customer_address: selectedProvinceData.name,
shipping_method: deliveryType === "PICK_UP" ? "pickup" : "standart", shipping_method: selectedOrderDelivery.name,
payment_type_id: paymentType.id, shipping_price: selectedOrderDelivery.price,
region: selectedRegion, payment_type_id: paymentType.id,
note: note || undefined, region: selectedRegion,
notes: notes || undefined,
},
{
onSuccess: () => {
router.push(`/orders`);
}, },
{ }
onSuccess: () => { );
router.push(`/orders`); };
},
}
);
};
if (!isClient) return null; if (!isClient) return null;
if (isLoading) { if (isLoading) {
return ( return (
<div className="container mx-auto px-4 min-h-[90vh] flex items-center justify-center"> <div className="mx-auto px-2 md:px-4 lg:px-6 mb-18">
<p>{t("common.loading")}</p> <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> </div>
); );
} }
if (isError || cartItems.length === 0) { if (isError ) {
return ( return <ErrorPage />;
<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"> if (cartItems.length === 0) {
{t("cart_empty")} return <EmptyCart />;
</h2>
</div>
);
} }
return ( return (
<div className="container mx-auto px-2 md:px-4 lg:px-6 mb-18"> <div className="flex flex-col mx-auto max-w-[1504px] px-2 md:px-4 lg:px-6 mb-18">
<h1 className="text-3xl font-bold mb-6 pt-3">{t("cart")}</h1> <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 flex-col md:flex-row gap-6">
<div className="flex-1"> <div className="flex-1">
<Card className="p-6 rounded-xl"> <Card className="p-4 md:p-6 rounded-xl">
{Object.entries(itemsBySeller).map( {Object.entries(itemsBySeller).map(
([sellerId, { seller, items }]) => ( ([sellerId, { seller, items }]) => (
<div key={sellerId} className="mb-6"> <div key={sellerId} className="mb-6">
@@ -212,18 +234,28 @@ export default function CartPage() {
title: t("products"), title: t("products"),
value: `${totalAmount.toFixed(2)} TMT`, value: `${totalAmount.toFixed(2)} TMT`,
}, },
...(selectedOrderDelivery
? [
{
title: t("shipping_method"),
value: `${selectedOrderDelivery.price.toFixed(2)} TMT`,
},
]
: []),
], ],
footer: { footer: {
title: t("total_price"), title: t("total_price"),
value: `${totalAmount.toFixed(2)} TMT`, value: `${finalTotal.toFixed(2)} TMT`,
}, },
}, },
}} }}
paymentType={paymentType} paymentType={paymentType}
deliveryType={deliveryType} orderDeliveries={orderDeliveries}
selectedOrderDelivery={selectedOrderDelivery}
onOrderDeliveryChange={setSelectedOrderDelivery}
selectedRegion={selectedRegion} selectedRegion={selectedRegion}
selectedProvince={selectedProvince} selectedProvince={selectedProvince}
note={note} notes={notes}
regionGroups={regionGroups} regionGroups={regionGroups}
availableRegions={availableRegions} availableRegions={availableRegions}
paymentTypes={paymentTypes} paymentTypes={paymentTypes}
@@ -234,8 +266,7 @@ export default function CartPage() {
onNameChange={setName} onNameChange={setName}
onLastNameChange={setLastName} onLastNameChange={setLastName}
onPaymentTypeChange={setPaymentType} onPaymentTypeChange={setPaymentType}
onDeliveryTypeChange={handleDeliveryTypeChange} onRegionChange={handleRegionChange}
onRegionChange={setSelectedRegion}
onProvinceChange={setSelectedProvince} onProvinceChange={setSelectedProvince}
onNoteChange={setNote} onNoteChange={setNote}
onCompleteOrder={handleCompleteOrder} onCompleteOrder={handleCompleteOrder}

View File

@@ -1,36 +1,52 @@
import type { Metadata } from "next" import type { Metadata } from "next";
type Props = { 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> { 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 { return {
title: `${slug.charAt(0).toUpperCase() + slug.slice(1)} | E-Commerce`, title: `${slug}${meta.suffix}`,
description: `Browse ${slug} products in our store`, description: meta.description,
openGraph: { openGraph: {
locale, locale: meta.ogLocale,
type: "website", title: `${slug}${meta.suffix}`,
title: `${slug.charAt(0).toUpperCase() + slug.slice(1)} | E-Commerce`, description: meta.description,
description: `Browse ${slug} products in our store`,
}, },
} };
} }
export async function generateStaticParams() { export async function generateStaticParams() {
// Generate static params for popular categories const categories = ["electronics", "clothing", "home-garden"];
const categories = ["electronics", "clothing", "home-garden"] return categories.map((slug) => ({ slug }));
return categories.map((slug) => ({ slug }))
} }
export default async function CategoryPage(props: Props) { export default async function CategoryPage(props: Props) {
const params = await props.params const params = await props.params;
const { slug } = params const { slug } = params;
const CategoryPageClient = (await import("../../../../features/category/components/CategoryPageClient")).default const CategoryPageClient = (
return <CategoryPageClient params={params} /> 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 = { 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> { 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 { return {
title: `${slug.charAt(0).toUpperCase() + slug.slice(1)} | E-Commerce`, title,
description: `Browse ${slug} collection products in our store`, description,
openGraph: { openGraph: {
locale,
type: "website", type: "website",
title: `${slug.charAt(0).toUpperCase() + slug.slice(1)} | E-Commerce`, locale: meta.ogLocale,
description: `Browse ${slug} collection products in our store`, title,
description,
}, },
} };
} }
export async function generateStaticParams() { export async function generateStaticParams() {
// Generate static params for popular collections const collections = ["new-arrivals", "best-sellers", "featured"];
const collections = ["new-arrivals", "best-sellers", "featured"] return collections.map((slug) => ({ slug }));
return collections.map((slug) => ({ slug }))
} }
export default async function CollectionPage(props: Props) { export default async function CollectionPage(props: Props) {
const params = await props.params const params = await props.params;
const CollectionPageClient = ( const CollectionPageClient = (
await import("../../../../features/collections/components/CollectionPageClient") await import(
).default "../../../../features/collections/components/CollectionPageClient"
)
return <CollectionPageClient params={params} /> ).default;
}
return <CollectionPageClient params={params} />;
}

View File

@@ -5,6 +5,9 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import ProductCard from "@/features/home/components/ProductCard"; import ProductCard from "@/features/home/components/ProductCard";
import type { Favorite } from "@/lib/types/api"; 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() { export default function FavoritesPage() {
const t = useTranslations(); const t = useTranslations();
@@ -12,32 +15,39 @@ export default function FavoritesPage() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="container mx-auto px-4 py-8 min-h-screen"> <div className="mx-auto px-2 md:px-4 lg:px-6 pb-12 space-y-8 max-w-[1504px]">
<h1 className="text-3xl font-bold mb-6">{t("favorite_products")}</h1> <h1 className="bg-white text-3xl p-4 font-bold mb-0 pb-6">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> {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) => ( {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>
</div> </div>
); );
} }
if (isError || !favorites || favorites.length === 0) { if (isError) {
return ( return <ErrorPage />;
<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]"> if (!favorites || favorites.length === 0) {
<p className="text-2xl text-gray-400">{t("empty_favorites")}</p> return <EmptyFavorites />;
</div>
</div>
);
} }
return ( return (
<div className="container mx-auto px-2 md:px-4 lg:px-6 pb-12 space-y-8 max-w-[1504px] <div
"> className=" mx-auto px-2 md:px-4 lg:px-6 pb-12 space-y-8 max-w-[1504px]
<h1 className="bg-white text-3xl p-4 font-bold mb-0 pb-6">{t("favorite_products")}</h1> "
>
<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"> <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) => { {favorites.map((favorite: Favorite) => {
const product = favorite.product; const product = favorite.product;
@@ -50,7 +60,7 @@ export default function FavoritesPage() {
media.images_400x400 || media.images_400x400 ||
media.thumbnail media.thumbnail
) )
.filter(Boolean) || ["/placeholder-product.jpg"]; .filter(Boolean) || [Placeholder];
const formattedPrice = product.price_amount const formattedPrice = product.price_amount
? `${parseFloat(product.price_amount).toFixed(2)} TMT` ? `${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 { @layer utilities {
/* Özel Renk Sınıfları (CSS değişkenlerini kullanmak için) */ .text-fg {
.text-fg { color: var(--fg); } color: var(--fg);
.bg-bg { background-color: var(--bg); } }
.stroke-primary { stroke: #005bff; } .bg-bg {
background-color: var(--bg);
/* Dark mode track rengi için özel sınıf */ }
.stroke-primary {
stroke: #005bff;
}
.stroke-track { .stroke-track {
stroke: hsla(var(--hue), 10%, 10%, 0.1); stroke: hsla(var(--hue), 10%, 10%, 0.1);
transition: stroke var(--trans-dur); transition: stroke var(--trans-dur);
@@ -145,48 +147,90 @@
} }
} }
/* Animasyon Sınıfları */ .animate-msg {
.animate-msg { animation: msg 0.3s 13.7s linear forwards; } 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-msgLast {
.animate-cartTop { animation: cartTop 2s ease-in-out infinite; } animation: msg 0.3s 14s linear reverse forwards;
.animate-cartWheel1 { }
.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; animation: cartWheel1 2s ease-in-out infinite;
transform: rotate(-0.25turn); transform: rotate(-0.25turn);
transform-origin: 43px 111px; transform-origin: 43px 111px;
} }
.animate-cartWheel2 { .animate-cartWheel2 {
animation: cartWheel2 2s ease-in-out infinite; animation: cartWheel2 2s ease-in-out infinite;
transform: rotate(0.25turn); transform: rotate(0.25turn);
transform-origin: 102px 111px; 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 { @keyframes msg {
from { opacity: 1; visibility: visible; } from {
99.9% { opacity: 0; visibility: visible; } opacity: 1;
to { opacity: 0; visibility: hidden; } visibility: visible;
}
99.9% {
opacity: 0;
visibility: visible;
}
to {
opacity: 0;
visibility: hidden;
}
} }
@keyframes cartLines { @keyframes cartLines {
from, to { opacity: 0; } from,
8%, 92% { opacity: 1; } to {
opacity: 0;
}
8%,
92% {
opacity: 1;
}
} }
@keyframes cartTop { @keyframes cartTop {
from { stroke-dashoffset: -338; } from {
50% { stroke-dashoffset: 0; } stroke-dashoffset: -338;
to { stroke-dashoffset: 338; } }
50% {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: 338;
}
} }
@keyframes cartWheel1 { @keyframes cartWheel1 {
from { transform: rotate(-0.25turn); } from {
to { transform: rotate(2.75turn); } transform: rotate(-0.25turn);
}
to {
transform: rotate(2.75turn);
}
} }
@keyframes cartWheel2 { @keyframes cartWheel2 {
from { transform: rotate(0.25turn); } from {
to { transform: rotate(3.25turn); } transform: rotate(0.25turn);
}
to {
transform: rotate(3.25turn);
}
} }
@keyframes cartWheelStroke { @keyframes cartWheelStroke {
from, to { stroke-dashoffset: 81.68; } from,
50% { stroke-dashoffset: 40.84; } 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 Header from "@/components/layout/Header"
import MobileBottomNav from "@/components/layout/MobileBar" import MobileBottomNav from "@/components/layout/MobileBar"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import { Providers } from "@/context/provider" import { Providers } from "@/context/Provider"
import AuthWrapper from "@/context/AuthWrapper" import AuthWrapper from "@/context/AuthWrapper"
const geistSans = Geist({ const geistSans = Geist({

View File

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

View File

@@ -23,7 +23,6 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
} }
export async function generateStaticParams() { export async function generateStaticParams() {
// Generate static params for popular products
return [{ slug: "nike-air-max" }, { slug: "adidas-ultraboost" }]; 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 React from "react";
import { useTranslations } from "next-intl";
const Preloader: React.FC = () => { const Preloader: React.FC = () => {
const t =useTranslations();
return ( 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="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"> <div className="text-center max-w-[20em] w-full">
@@ -21,15 +21,14 @@ const Preloader: React.FC = () => {
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="8" strokeWidth="8"
> >
{/* Track (Arka plan izleri) */}
<g className="stroke-track"> <g className="stroke-track">
<polyline points="4,4 21,4 26,22 124,22 112,64 35,64 39,80 106,80" /> <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="43" cy="111" r="13" />
<circle cx="102" cy="111" r="13" /> <circle cx="102" cy="111" r="13" />
</g> </g>
{/* Hareketli Çizgiler */}
{/* animate-cartLines sınıfı globals.css'ten geliyor */}
<g className="stroke-primary animate-cartLines"> <g className="stroke-primary animate-cartLines">
<polyline <polyline
className="animate-cartTop" className="animate-cartTop"
@@ -63,14 +62,14 @@ const Preloader: React.FC = () => {
</g> </g>
</svg> </svg>
{/* Yükleniyor Yazıları */}
<div className="relative h-6"> <div className="relative h-6">
<p className="absolute w-full animate-msg text-lg"> <p className="absolute w-full animate-msg text-lg">
Bringing you the goods {t('loading')}
</p> </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. This is taking long. Somethings wrong.
</p> </p> */}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,10 @@
// Header.tsx
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; 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 { Button } from "@/components/ui/button";
import Logo from "@/public/logo.webp"; import Logo from "@/public/logo.webp";
import CategoryMenu from "./ui/CategoryMenu"; import CategoryMenu from "./ui/CategoryMenu";
@@ -12,10 +13,9 @@ import AuthDialog from "./ui/AuthDialog";
import ActionButtons from "./ui/ActionButtons"; import ActionButtons from "./ui/ActionButtons";
import LanguageSelector from "./ui/LanguageSelector"; import LanguageSelector from "./ui/LanguageSelector";
import MobileBottomNav from "./MobileBar"; import MobileBottomNav from "./MobileBar";
import { useAuthStatus, useLogout } from "@/lib/hooks/useAuth"; import { useAuthStatus } from "@/lib/hooks/useAuth";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { CategoryIcon } from "../icons"; import { CategoryIcon } from "../icons";
import { useRouter } from "next/navigation";
interface HeaderProps { interface HeaderProps {
locale?: string; locale?: string;
@@ -27,10 +27,8 @@ export default function Header({ locale = "ru" }: HeaderProps) {
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const [isLoginOpen, setIsLoginOpen] = useState(false); const [isLoginOpen, setIsLoginOpen] = useState(false);
const t = useTranslations(); const t = useTranslations();
const router = useRouter();
const { isAuthenticated, isLoading } = useAuthStatus(); const { isAuthenticated } = useAuthStatus();
const { mutate: logout, isPending: isLoggingOut } = useLogout();
useEffect(() => { useEffect(() => {
setIsClient(true); setIsClient(true);
@@ -44,10 +42,6 @@ export default function Header({ locale = "ru" }: HeaderProps) {
} }
}, [isAuthenticated, locale]); }, [isAuthenticated, locale]);
const handleLogout = useCallback(() => {
logout();
}, [logout]);
const toggleCategoryMenu = useCallback(() => { const toggleCategoryMenu = useCallback(() => {
setIsCategoryOpen((prev) => !prev); setIsCategoryOpen((prev) => !prev);
}, []); }, []);
@@ -56,16 +50,12 @@ export default function Header({ locale = "ru" }: HeaderProps) {
setIsCategoryOpen(false); setIsCategoryOpen(false);
}, []); }, []);
const handleProfileClick = useCallback(() => {
router.push(`/${locale}/me`);
}, [router, locale]);
if (!isClient) return null; if (!isClient) return null;
return ( return (
<> <>
<header className="sticky top-0 z-50 w-full border-b bg-white shadow-sm"> <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"> <div className="flex h-16 items-center justify-between gap-3">
<Link href="/" className="shrink-0"> <Link href="/" className="shrink-0">
<div className="relative h-8 w-[180px]"> <div className="relative h-8 w-[180px]">
@@ -80,15 +70,16 @@ export default function Header({ locale = "ru" }: HeaderProps) {
</Link> </Link>
<Button <Button
data-catalog-trigger
onClick={toggleCategoryMenu} 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" size="lg"
> >
{isCategoryOpen ? <X className="h-5 w-5" /> : <CategoryIcon />} {isCategoryOpen ? <X className="h-5 w-5" /> : <CategoryIcon />}
{t("common.catalog")} {t("common.catalog")}
</Button> </Button>
<div className="flex items-center gap-2 sm:hidden"> <div className="flex items-center gap-2 sm:hidden cursor-pointer">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -131,10 +122,11 @@ export default function Header({ locale = "ru" }: HeaderProps) {
<AuthDialog isOpen={isLoginOpen} onClose={() => setIsLoginOpen(false)} /> <AuthDialog isOpen={isLoginOpen} onClose={() => setIsLoginOpen(false)} />
<MobileBottomNav <MobileBottomNav
locale={locale} locale={locale}
onLoginClick={() => {
onLoginClick={() => setIsLoginOpen(true)} setIsLoginOpen(true);
/> }}
/>
</> </>
); );
} }

View File

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

View File

@@ -12,7 +12,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } 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 { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useLogout } from "@/lib/hooks/useAuth"; import { useLogout } from "@/lib/hooks/useAuth";
@@ -53,10 +53,7 @@ export default function ActionButtons({
const { data: ordersData, isLoading: ordersLoading } = useOrders(); const { data: ordersData, isLoading: ordersLoading } = useOrders();
// Calculate cart count from cart items array // Calculate cart count from cart items array
const cartCount = useMemo(() => { const cartCount = useCartCount()
if (!cartData?.data) return 0;
return cartData.data.length;
}, [cartData]);
// Calculate favorites count // Calculate favorites count
const favoritesCount = useMemo(() => { const favoritesCount = useMemo(() => {
@@ -70,8 +67,13 @@ export default function ActionButtons({
return Array.isArray(ordersData) ? ordersData.length : 0; return Array.isArray(ordersData) ? ordersData.length : 0;
}, [ordersData]); }, [ordersData]);
const handleLogout = () => { const handleLogout = () => {
logout(); logout(undefined, {
onSuccess: () => {
router.push(`/${locale}`);
router.refresh();
}
});
}; };
const buttons: ActionButtonData[] = useMemo( const buttons: ActionButtonData[] = useMemo(
@@ -115,7 +117,7 @@ export default function ActionButtons({
); );
return ( return (
<div className="hidden items-center gap-1 md:flex"> <div className="hidden items-center gap-1 lg:flex">
{/* Profile/Login Button with Dropdown */} {/* Profile/Login Button with Dropdown */}
{authLoading ? ( {authLoading ? (
<div className="h-10 w-24 animate-pulse bg-gray-200 rounded" /> <div className="h-10 w-24 animate-pulse bg-gray-200 rounded" />
@@ -125,7 +127,7 @@ export default function ActionButtons({
<Button <Button
variant="ghost" variant="ghost"
size="sm" 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 /> <ProfileIcon />
<span className="text-xs text-gray-700">{t("profile")}</span> <span className="text-xs text-gray-700">{t("profile")}</span>
@@ -146,7 +148,7 @@ export default function ActionButtons({
<Button <Button
variant="ghost" variant="ghost"
size="sm" 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} onClick={onAuthClick}
> >
<ProfileIcon /> <ProfileIcon />

View File

@@ -4,10 +4,15 @@ import { useState, useCallback } from "react";
import Image from "next/image"; import Image from "next/image";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; 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 { toast } from "sonner";
import Logo from "@/public/logo.webp"; import Logo from "@/public/logo.webp";
import { useLogin, useVerifyToken } from "@/lib/hooks/useAuth"; import { useLogin, useRegister, useVerifyToken } from "@/lib/hooks/useAuth";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
interface AuthDialogProps { interface AuthDialogProps {
@@ -15,59 +20,148 @@ interface AuthDialogProps {
onClose: () => void; onClose: () => void;
} }
type AuthStep = "phone" | "register" | "verify";
export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) { export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
const [phone, setPhone] = useState("993"); const [phone, setPhone] = useState("+993 ");
const [name, setName] = useState("");
const [address, setAddress] = useState("");
const [otp, setOtp] = useState(""); const [otp, setOtp] = useState("");
const [otpSent, setOtpSent] = useState(false); const [authStep, setAuthStep] = useState<AuthStep>("phone");
const [rawPhone, setRawPhone] = useState(""); const [isNewUser, setIsNewUser] = useState(false);
const t = useTranslations(); const t = useTranslations();
const { mutate: login, isPending: isLoginLoading } = useLogin(); const { mutate: login, isPending: isLoginLoading } = useLogin();
const { mutate: register, isPending: isRegisterLoading } = useRegister();
const { mutate: verifyToken, isPending: isVerifyLoading } = useVerifyToken(); const { mutate: verifyToken, isPending: isVerifyLoading } = useVerifyToken();
const resetDialog = useCallback(() => { const resetDialog = useCallback(() => {
setOtpSent(false); setAuthStep("phone");
setPhone("993"); setPhone("+993 ");
setName("");
setAddress("");
setOtp(""); setOtp("");
setRawPhone(""); setIsNewUser(false);
onClose(); onClose();
}, [onClose]); }, [onClose]);
const handleSendOtp = useCallback(() => { const formatPhoneForBackend = (phoneNumber: string): string => {
const cleanPhone = phone.replace(/\D/g, ""); return phoneNumber.replace(/^\+993\s*/, "").replace(/\s+/g, "");
};
if (cleanPhone.length !== 11 || !cleanPhone.startsWith("993")) {
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 handleCheckPhone = useCallback(() => {
if (!isPhoneValid()) {
toast.error(t("invalid_phone")); toast.error(t("invalid_phone"));
return; return;
} }
const phoneNumber = cleanPhone.substring(3); const phoneNumber = formatPhoneForBackend(phone);
setRawPhone(phoneNumber);
// Try to login first to check if user exists
login( login(
{ phone_number: phoneNumber }, { phone_number: parseInt(phoneNumber, 10) },
{ {
onSuccess: () => { onSuccess: () => {
toast.success(t("code_sent")); toast.success(t("code_sent"));
setOtpSent(true); setIsNewUser(false);
setAuthStep("verify");
},
onError: (error: any) => {
// Check if error indicates user not found
const errorMessage = error?.response?.data?.message || "";
const lowerMessage = errorMessage.toLowerCase();
if (
lowerMessage.includes("tapylmady") ||
lowerMessage.includes("not found") ||
lowerMessage.includes("does not exist") ||
lowerMessage.includes("not exist") ||
error?.response?.status === 404
) {
// User doesn't exist, show registration form
setIsNewUser(true);
setAuthStep("register");
} else {
toast.error(errorMessage || t("error_occurred"));
}
},
},
);
}, [phone, login, t]);
const handleRegister = useCallback(() => {
if (!name.trim()) {
toast.error(t("name_required") || "Adyňyzy giriziň");
return;
}
if (!address.trim()) {
toast.error(t("address_required") || "Salgyňyzy giriziň");
return;
}
const phoneNumber = formatPhoneForBackend(phone);
register(
{
phone_number: phoneNumber,
name: name.trim(),
address: address.trim(),
},
{
onSuccess: () => {
toast.success(
t("registration_success") || "Hasaba alyndy! Kody giriziň",
);
setAuthStep("verify");
}, },
onError: (error: any) => { onError: (error: any) => {
toast.error(error?.response?.data?.message || t("error_occurred")); toast.error(error?.response?.data?.message || t("error_occurred"));
}, },
} },
); );
}, [phone, login, t]); }, [phone, name, address, register, t]);
const handleLogin = useCallback(() => { const handleVerify = useCallback(() => {
if (otp.length < 4) { if (otp.length < 4) {
toast.error(t("invalid_code")); toast.error(t("invalid_code"));
return; return;
} }
const phoneNumber = formatPhoneForBackend(phone);
verifyToken( verifyToken(
{ {
phone_number: rawPhone, phone_number: parseInt(phoneNumber, 10),
code: otp, code: parseInt(otp, 10),
}, },
{ {
onSuccess: () => { onSuccess: () => {
@@ -78,23 +172,44 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
onError: (error: any) => { onError: (error: any) => {
toast.error(error?.response?.data?.message || t("wrong_code")); toast.error(error?.response?.data?.message || t("wrong_code"));
}, },
} },
); );
}, [otp, rawPhone, verifyToken, resetDialog, t]); }, [otp, phone, verifyToken, resetDialog, t]);
const handleKeyPress = useCallback((e: React.KeyboardEvent, action: () => void) => { const handleKeyPress = useCallback(
if (e.key === "Enter") { (e: React.KeyboardEvent, action: () => void) => {
action(); if (e.key === "Enter") {
} action();
}, []); }
},
[],
);
const formatPhoneInput = useCallback((value: string) => { const getTitle = () => {
const cleaned = value.replace(/\D/g, ""); switch (authStep) {
if (!cleaned.startsWith("993")) { case "phone":
return "993"; return t("common.enterPhone");
case "register":
return t("register_title") || "Hasaba alyş";
case "verify":
return t("verify_title") || "Kody giriziň";
default:
return t("common.enterPhone");
} }
return cleaned.substring(0, 11); };
}, []);
const getDescription = () => {
switch (authStep) {
case "phone":
return t("common.weWillSendCode");
case "register":
return t("register_description") || "Maglumatyňyzy dolduryň";
case "verify":
return t("verify_description") || "Telefonyňyza gelen kody giriziň";
default:
return t("common.weWillSendCode");
}
};
return ( return (
<Dialog open={isOpen} onOpenChange={resetDialog}> <Dialog open={isOpen} onOpenChange={resetDialog}>
@@ -105,55 +220,125 @@ export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
<Image src={Logo} alt="Logo" fill className="object-contain" /> <Image src={Logo} alt="Logo" fill className="object-contain" />
</div> </div>
</div> </div>
<DialogTitle className="text-2xl text-center">{t("common.enterPhone")}</DialogTitle> <DialogTitle className="text-2xl text-center">
<p className="text-center text-sm text-gray-600">{t("common.weWillSendCode")}</p> {getTitle()}
</DialogTitle>
<p className="text-center text-sm text-gray-600">
{getDescription()}
</p>
</DialogHeader> </DialogHeader>
<div className="space-y-4 mt-4"> <div className="space-y-4 mt-4">
{/* Phone Input - Always shown but disabled after first step */}
<div> <div>
<Input <Input
type="tel" type="tel"
placeholder={t("common.phone")} placeholder="+993 61 097651"
value={phone} value={phone}
onChange={(e) => setPhone(formatPhoneInput(e.target.value))} onChange={handlePhoneChange}
className="h-12 rounded-xl" className="h-12 rounded-xl"
onKeyDown={(e) => handleKeyPress(e, handleSendOtp)} onKeyDown={(e) => handleKeyPress(e, handleCheckPhone)}
disabled={otpSent || isLoginLoading} disabled={authStep !== "phone" || isLoginLoading}
maxLength={11}
/> />
<p className="text-xs text-gray-500 mt-1">{t("phone_format")}</p> <p className="text-xs text-gray-500 mt-1">{t("phone_format")}</p>
</div> </div>
{otpSent && ( {/* Registration Form */}
{authStep === "register" && (
<>
<Input
type="text"
placeholder={t("name_placeholder") || "Adyňyz"}
value={name}
onChange={(e) => setName(e.target.value)}
className="h-12 rounded-xl"
disabled={isRegisterLoading}
autoFocus
/>
<Input
type="text"
placeholder={
t("address_placeholder") || "Salgyňyz (mysal: Tejen)"
}
value={address}
onChange={(e) => setAddress(e.target.value)}
className="h-12 rounded-xl"
onKeyDown={(e) => handleKeyPress(e, handleRegister)}
disabled={isRegisterLoading}
/>
</>
)}
{/* Verification Code Input */}
{authStep === "verify" && (
<Input <Input
type="text" type="text"
placeholder={t("common.code")} placeholder={t("common.code")}
value={otp} 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" className="h-12 rounded-xl"
onKeyDown={(e) => handleKeyPress(e, handleLogin)} onKeyDown={(e) => handleKeyPress(e, handleVerify)}
disabled={isVerifyLoading} disabled={isVerifyLoading}
autoFocus autoFocus
maxLength={6} maxLength={6}
/> />
)} )}
{/* Action Button */}
<Button <Button
onClick={otpSent ? handleLogin : handleSendOtp} onClick={
className="w-full h-12 rounded-xl font-bold text-base bg-[#005bff] hover:bg-[#0041c4]" authStep === "phone"
? handleCheckPhone
: authStep === "register"
? handleRegister
: handleVerify
}
className="w-full cursor-pointer h-12 rounded-xl font-bold text-base bg-[#005bff] hover:bg-[#0041c4]"
size="lg" size="lg"
disabled={isLoginLoading || isVerifyLoading} disabled={
isLoginLoading ||
isRegisterLoading ||
isVerifyLoading ||
(authStep === "phone" && !isPhoneValid())
}
> >
{isLoginLoading {isLoginLoading
? t("sending") ? t("checking") || "Barlanýar..."
: isVerifyLoading : isRegisterLoading
? t("verifying") ? t("registering") || "Hasaba alynýar..."
: otpSent : isVerifyLoading
? t("verify") ? t("verifying")
: t("common.send")} : authStep === "phone"
? t("common.send")
: authStep === "register"
? t("register_button") || "Hasaba al"
: t("verify")}
</Button> </Button>
{/* Back Button for Register and Verify steps */}
{authStep !== "phone" && (
<Button
onClick={() => {
if (authStep === "register") {
setAuthStep("phone");
setName("");
setAddress("");
} else if (authStep === "verify") {
setAuthStep(isNewUser ? "register" : "phone");
setOtp("");
}
}}
variant="ghost"
className="w-full"
disabled={isLoginLoading || isRegisterLoading || isVerifyLoading}
>
{t("back") || "Yza"}
</Button>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }

View File

@@ -1,80 +1,149 @@
"use client" // CategoryMenu.tsx
"use client";
import { useState } from "react"
import Link from "next/link"
import { useCategories } from "@/lib/hooks"
import { Skeleton } from "@/components/ui/skeleton"
import { useState, useEffect, useRef } from "react";
import Link from "next/link";
import { useCategories } from "@/lib/hooks";
import { Skeleton } from "@/components/ui/skeleton";
interface CategoryMenuProps { interface CategoryMenuProps {
isOpen: boolean isOpen: boolean;
onClose: () => void onClose: () => void;
} }
export default function CategoryMenu({ isOpen, onClose }: CategoryMenuProps) { export default function CategoryMenu({ isOpen, onClose }: CategoryMenuProps) {
const [hoveredCategory, setHoveredCategory] = useState<number | null>(null) const [hoveredCategory, setHoveredCategory] = useState<number | null>(null);
const { data: categories, isLoading } = useCategories() 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 handleClickOutside = (event: MouseEvent) => {
const activeCategory = hoveredCategory !== null ? categoryList[hoveredCategory] : null 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 ( 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="fixed inset-0 bg-black/20 z-30" onClick={onClose} />
<div className="flex">
<CategoryList
categories={categoryList}
isLoading={isLoading}
onCategoryHover={setHoveredCategory}
onCategoryClick={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> </div>
</div> </>
) );
} }
interface CategoryListProps { interface CategoryListProps {
categories: any[] categories: any[];
isLoading: boolean isLoading: boolean;
onCategoryHover: (index: number) => void onCategoryHover: (index: number) => void;
onCategoryClick: () => void onCategoryClick: () => void;
} }
function CategoryList({ categories, isLoading, onCategoryHover, onCategoryClick }: CategoryListProps) { function CategoryList({
categories,
isLoading,
onCategoryHover,
onCategoryClick,
}: CategoryListProps) {
return ( return (
<div className="w-[280px] border-r"> <div className="w-[280px] border-r">
<div className="max-h-[calc(100vh-4rem)] overflow-y-auto py-2"> <div className="max-h-[calc(100vh-4rem)] overflow-y-auto py-2">
{isLoading {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) => ( : categories.map((category, index) => (
<Link <Link
key={category.id} key={category.id}
href={`/category/${category.slug}?category_id=${category.id}`} href={`/category/${category.slug}?category_id=${category.id}`}
onClick={onCategoryClick} onClick={onCategoryClick}
onMouseEnter={() => onCategoryHover(index)} 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> <span>{category.name}</span>
</Link> </Link>
))} ))}
</div> </div>
</div> </div>
) );
} }
interface SubcategoryListProps { interface SubcategoryListProps {
category: any category: any;
onSubcategoryClick: () => void onSubcategoryClick: () => void;
} }
function SubcategoryList({ category, onSubcategoryClick }: SubcategoryListProps) { function SubcategoryList({
category,
onSubcategoryClick,
}: SubcategoryListProps) {
return ( return (
<div className="flex-1 p-6"> <div className="flex-1 p-6">
<h3 className="text-xl font-semibold mb-4">{category.name}</h3> <h3 className="text-xl font-semibold mb-4">{category.name}</h3>
@@ -91,5 +160,5 @@ function SubcategoryList({ category, onSubcategoryClick }: SubcategoryListProps)
))} ))}
</div> </div>
</div> </div>
) );
} }

View File

@@ -92,9 +92,9 @@ export default function SearchBar({
<button <button
key={product.id} key={product.id}
onClick={() => handleProductClick(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 <Image
src={product.thumbnail} src={product.thumbnail}
alt={product.name} alt={product.name}
@@ -157,7 +157,7 @@ export default function SearchBar({
</div> </div>
<Button <Button
size="icon" 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 /> <SearchIcon />
</Button> </Button>

View File

@@ -14,7 +14,7 @@ function Checkbox({
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
data-slot="checkbox" data-slot="checkbox"
className={cn( 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 className
)} )}
{...props} {...props}

View File

@@ -45,7 +45,7 @@ function Slider({
<SliderPrimitive.Range <SliderPrimitive.Range
data-slot="slider-range" data-slot="slider-range"
className={cn( 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> </SliderPrimitive.Track>

View File

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

View File

@@ -1,10 +1,13 @@
// components/AuthWrapper.tsx
"use client"; "use client";
import { useEffect, type ReactNode } from "react"; 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 { useAuthStatus, useGetGuestToken } from "@/lib/hooks/useAuth";
import { useUserProfile } from "@/features/profile/hooks/useUserProfile"; import { useUserProfile } from "@/features/profile/hooks/useUserProfile";
import Preloader from "@/components/PageLoader/PreLoader"; import Preloader from "@/components/PageLoader/PreLoader";
import TokenStorage from "@/lib/tokenStorage";
interface AuthWrapperProps { interface AuthWrapperProps {
children: ReactNode; children: ReactNode;
@@ -20,48 +23,39 @@ export default function AuthWrapper({
locale, locale,
}: AuthWrapperProps) { }: AuthWrapperProps) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
const { isAuthenticated, isLoading } = useAuthStatus(); const { isAuthenticated, isLoading } = useAuthStatus();
const { mutate: getGuestToken, isPending: isGettingGuestToken } = useGetGuestToken(); const { mutate: getGuestToken, isPending: isGettingGuestToken } =
useGetGuestToken();
// Login olmuş kullanıcı için profil bilgisini otomatik çek
useUserProfile(); useUserProfile();
useEffect(() => { useEffect(() => {
if (isLoading) return; if (isLoading) return;
const authToken = document.cookie // Only fetch guest token once on initial mount if no token exists
.split("; ") if (!TokenStorage.hasAnyToken() && !isGettingGuestToken) {
.find(row => row.startsWith("authToken="));
const guestToken = document.cookie
.split("; ")
.find(row => row.startsWith("guestToken="));
if (!authToken && !guestToken && !isGettingGuestToken) {
getGuestToken(); getGuestToken();
} }
}, [isLoading, getGuestToken, isGettingGuestToken]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isLoading]); // Only run when isLoading changes (initial mount)
useEffect(() => { useEffect(() => {
if (isLoading || isGettingGuestToken) return; if (isLoading || isGettingGuestToken) return;
if (requireAuth && !isAuthenticated) { if (requireAuth && !isAuthenticated) {
const redirect = redirectTo || `/${locale}/login`; router.push(`/${locale}`);
const returnUrl = pathname !== redirect ? `?returnUrl=${encodeURIComponent(pathname)}` : "";
router.push(`${redirect}${returnUrl}`);
return; return;
} }
}, [
if (isAuthenticated && (pathname.includes("/login") || pathname.includes("/register"))) { isAuthenticated,
router.push(`/${locale}`); isLoading,
} requireAuth,
}, [isAuthenticated, isLoading, requireAuth, pathname, router, locale, redirectTo, isGettingGuestToken]); router,
locale,
isGettingGuestToken,
]);
if (isLoading || (requireAuth && !isAuthenticated)) { if (isLoading || (requireAuth && !isAuthenticated)) {
return ( return <Preloader />;
<Preloader/>
);
} }
return <>{children}</>; return <>{children}</>;
} }

22
context/Provider.tsx Normal file
View File

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

View File

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

View File

@@ -1,323 +1,377 @@
"use client" "use client";
import { useState, useEffect, useRef, useCallback } from "react" import { useState, useEffect, useRef, useCallback } from "react";
import Image from "next/image" import Image from "next/image";
import { Minus, Plus, Trash2, Loader2, AlertTriangle } from "lucide-react" import { Minus, Plus, Trash2, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card" import { Card } from "@/components/ui/card";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog";
import { useUpdateCartItemQuantity, useRemoveFromCart } from "@/lib/hooks" import { useUpdateCartItemQuantity, useRemoveFromCart } from "@/lib/hooks";
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl";
import type { CartItem } from "@/lib/types/api" import type { CartItem } from "@/lib/types/api";
interface CartItemCardProps { interface CartItemCardProps {
item: CartItem item: CartItem;
onUpdate?: () => void onUpdate?: () => void;
} }
// Session Storage Key // Session Storage Key
const PENDING_CART_UPDATES_KEY = 'pendingCartUpdates' const PENDING_CART_UPDATES_KEY = "pendingCartUpdates";
interface PendingUpdate { interface PendingUpdate {
quantity: number quantity: number;
timestamp: number timestamp: number;
retryCount: number retryCount: number;
} }
export default function CartItemCard({ item, onUpdate }: CartItemCardProps) { export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
const t = useTranslations() 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 { mutate: updateQuantity } = useUpdateCartItemQuantity() // Local UI State (Instant feedback)
const { mutate: removeItem, isPending: isRemoving } = useRemoveFromCart() 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);
const isInitializedRef = useRef(false); // Track if component has been initialized
// 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 // Get available stock
const availableStock = item.product.stock || 0 const availableStock = item.product.stock || 0;
// Initialize from server state // Initialize from server state
useEffect(() => { useEffect(() => {
setLocalQuantity(item.quantity) setLocalQuantity(item.quantity);
}, [item.quantity]) // Mark as initialized after first render
if (!isInitializedRef.current) {
isInitializedRef.current = true;
}
}, [item.quantity]);
// Save to sessionStorage // Save to sessionStorage
const savePendingUpdate = useCallback((quantity: number) => { const savePendingUpdate = useCallback(
try { (quantity: number) => {
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY) try {
const pending: Record<number, PendingUpdate> = stored ? JSON.parse(stored) : {} const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY);
const pending: Record<number, PendingUpdate> = stored
pending[item.product_id] = { ? JSON.parse(stored)
quantity, : {};
timestamp: Date.now(),
retryCount: retryCountRef.current 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)) [item.product_id],
} catch (error) { );
console.error('Failed to save pending update:', error)
}
}, [item.product_id])
// Remove from sessionStorage // Remove from sessionStorage
const clearPendingUpdate = useCallback(() => { const clearPendingUpdate = useCallback(() => {
try { try {
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY) const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY);
if (stored) { if (stored) {
const pending: Record<number, PendingUpdate> = JSON.parse(stored) const pending: Record<number, PendingUpdate> = JSON.parse(stored);
delete pending[item.product_id] delete pending[item.product_id];
if (Object.keys(pending).length === 0) { if (Object.keys(pending).length === 0) {
sessionStorage.removeItem(PENDING_CART_UPDATES_KEY) sessionStorage.removeItem(PENDING_CART_UPDATES_KEY);
} else { } else {
sessionStorage.setItem(PENDING_CART_UPDATES_KEY, JSON.stringify(pending)) sessionStorage.setItem(
PENDING_CART_UPDATES_KEY,
JSON.stringify(pending),
);
} }
} }
} catch (error) { } 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 // Exponential backoff retry
const retrySync = useCallback((quantity: number) => { const retrySync = useCallback((quantity: number) => {
const maxRetries = 4 const maxRetries = 4;
const retryCount = retryCountRef.current const retryCount = retryCountRef.current;
if (retryCount >= maxRetries) { if (retryCount >= maxRetries) {
setSyncError(true) setSyncError(true);
setIsSyncing(false) setIsSyncing(false);
return return;
} }
const delay = Math.min(1000 * Math.pow(2, retryCount), 16000) // Max 16s const delay = Math.min(1000 * Math.pow(2, retryCount), 16000); // Max 16s
retryCountRef.current++ retryCountRef.current++;
retryTimerRef.current = setTimeout(() => { retryTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(quantity) syncToServerRef.current?.(quantity);
}, delay) }, delay);
}, []) }, []);
// Update ref // Update ref
retrySyncRef.current = retrySync retrySyncRef.current = retrySync;
// Sync to server // Sync to server
const syncToServer = useCallback((quantity: number) => { const syncToServer = useCallback(
// If already syncing, queue this update (quantity: number) => {
if (isRequestInFlightRef.current) { // If already syncing, queue this update
pendingQuantityRef.current = quantity if (isRequestInFlightRef.current) {
return pendingQuantityRef.current = quantity;
} return;
}
// Mark as syncing // Mark as syncing
isRequestInFlightRef.current = true isRequestInFlightRef.current = true;
setIsSyncing(true) setIsSyncing(true);
setSyncError(false) setSyncError(false);
if (quantity <= 0) { if (quantity <= 0) {
removeItem(item.product_id, { 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 },
{
onSuccess: () => { onSuccess: () => {
isRequestInFlightRef.current = false isRequestInFlightRef.current = false;
setIsSyncing(false) setIsSyncing(false);
retryCountRef.current = 0 retryCountRef.current = 0;
clearPendingUpdate() clearPendingUpdate();
onUpdate?.() onUpdate?.();
// Process queued update if any // Process queued update if any
if (pendingQuantityRef.current !== null) { if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current const nextQuantity = pendingQuantityRef.current;
pendingQuantityRef.current = null pendingQuantityRef.current = null;
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100) setTimeout(() => syncToServerRef.current?.(nextQuantity), 100);
} }
}, },
onError: (error) => { onError: (error) => {
console.error('Update failed:', error) console.error("Remove failed:", error);
isRequestInFlightRef.current = false isRequestInFlightRef.current = false;
retrySyncRef.current?.(quantity);
// Rollback on error after retries exhausted },
if (retryCountRef.current >= 3) { });
setLocalQuantity(item.quantity) } else {
clearPendingUpdate() updateQuantity(
} { productId: item.product_id, quantity },
{
retrySyncRef.current?.(quantity) onSuccess: () => {
} isRequestInFlightRef.current = false;
} setIsSyncing(false);
) retryCountRef.current = 0;
} clearPendingUpdate();
}, [item.product_id, item.quantity, updateQuantity, removeItem, onUpdate, 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,
],
);
// Update ref // Update ref
syncToServerRef.current = syncToServer syncToServerRef.current = syncToServer;
// Load pending updates from sessionStorage on mount // Load pending updates from sessionStorage on mount
// DISABLED: This was causing automatic sync on mount, sending 0 quantity to server
// Users should manually refresh or re-add items if there were pending updates
/*
useEffect(() => { useEffect(() => {
const loadPendingUpdates = () => { const loadPendingUpdates = () => {
try { try {
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY) const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY);
if (stored) { if (stored) {
const pending: Record<number, PendingUpdate> = JSON.parse(stored) const pending: Record<number, PendingUpdate> = JSON.parse(stored);
const productPending = pending[item.product_id] const productPending = pending[item.product_id];
if (productPending && productPending.quantity !== item.quantity) { if (productPending && productPending.quantity !== item.quantity) {
// Apply pending update // Apply pending update
setLocalQuantity(productPending.quantity) setLocalQuantity(productPending.quantity);
pendingQuantityRef.current = productPending.quantity pendingQuantityRef.current = productPending.quantity;
retryCountRef.current = productPending.retryCount retryCountRef.current = productPending.retryCount;
// Trigger sync after a short delay // Trigger sync after a short delay
setTimeout(() => syncToServerRef.current?.(productPending.quantity), 500) setTimeout(
() => syncToServerRef.current?.(productPending.quantity),
500
);
} }
} }
} catch (error) { } catch (error) {
console.error('Failed to load pending updates:', error) console.error("Failed to load pending updates:", error);
} }
} };
loadPendingUpdates() loadPendingUpdates();
}, [item.product_id, item.quantity]) }, [item.product_id, item.quantity]);
*/
// Debounced sync // Debounced sync
useEffect(() => { useEffect(() => {
// Don't sync on initial mount - only sync after user interaction
if (!isInitializedRef.current) {
return;
}
// Clear existing timers // Clear existing timers
if (debounceTimerRef.current) { if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
} }
// If local quantity matches server, no sync needed // If local quantity matches server, no sync needed
if (localQuantity === item.quantity) { if (localQuantity === item.quantity) {
return return;
}
// Don't sync if quantity is 0 or invalid (unless it's a delete operation)
if (localQuantity <= 0 && item.quantity > 0) {
// This is a delete operation, allow it
} else if (localQuantity <= 0) {
// Invalid state, don't sync
return;
} }
// Save to sessionStorage immediately // Save to sessionStorage immediately
savePendingUpdate(localQuantity) savePendingUpdate(localQuantity);
// Debounce the API call // Debounce the API call
debounceTimerRef.current = setTimeout(() => { debounceTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(localQuantity) syncToServerRef.current?.(localQuantity);
}, 800) }, 800);
return () => { return () => {
if (debounceTimerRef.current) { if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
} }
} };
}, [localQuantity, item.quantity, savePendingUpdate]) }, [localQuantity, item.quantity, savePendingUpdate]);
// Cleanup // Cleanup
useEffect(() => { useEffect(() => {
return () => { return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
if (retryTimerRef.current) clearTimeout(retryTimerRef.current) if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
} };
}, []) }, []);
const handleQuantityIncrease = (e: React.MouseEvent) => { const handleQuantityIncrease = (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault();
e.stopPropagation() e.stopPropagation();
// Check stock limit // Check stock limit
if (localQuantity >= availableStock) { if (localQuantity >= availableStock) {
setShowStockModal(true) setShowStockModal(true);
return return;
} }
// Optimistic update (instant UI feedback) // Optimistic update (instant UI feedback)
setLocalQuantity(prev => prev + 1) setLocalQuantity((prev) => prev + 1);
} };
const handleQuantityDecrease = (e: React.MouseEvent) => { const handleQuantityDecrease = (e: React.MouseEvent) => {
e.preventDefault() e.preventDefault();
e.stopPropagation() e.stopPropagation();
if (localQuantity <= 1) { if (localQuantity <= 1) {
handleDelete() handleDelete();
return return;
} }
// Optimistic update (instant UI feedback) // Optimistic update (instant UI feedback)
setLocalQuantity(prev => prev - 1) setLocalQuantity((prev) => prev - 1);
} };
const handleDelete = () => { const handleDelete = () => {
setLocalQuantity(0) setLocalQuantity(0);
clearPendingUpdate() clearPendingUpdate();
} };
const getImageSrc = () => { const getImageSrc = () => {
if (item.product.image) return item.product.image if (item.product.image) return item.product.image;
if (item.product.images && item.product.images.length > 0) return item.product.images[0] if (item.product.images && item.product.images.length > 0)
return "/placeholder.svg" return item.product.images[0];
} return "/placeholder.svg";
};
return ( return (
<> <>
<Card className="p-4 shadow-none border"> <Card className="p-4 shadow-none border">
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<div className="flex gap-4 flex-1"> <div className="flex gap-4 flex-1">
<div className="relative w-[88px] h-[117px] rounded-xl border overflow-hidden flex-shrink-0"> <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" /> <Image
src={getImageSrc()}
alt={item.product.name}
fill
className="object-contain"
/>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<h3 className="font-semibold text-base">{item.product.name}</h3> <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">
{availableStock <= 5 && ( {item.seller?.name || "Store"}
</p> */}
{/* {availableStock <= 5 && (
<p className="text-xs text-orange-600 font-medium"> <p className="text-xs text-orange-600 font-medium">
{t("only_left", { count: availableStock })} {t("only_left", { count: availableStock })}
</p> </p>
)} )} */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleDelete} onClick={handleDelete}
disabled={isRemoving} 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" /> <Trash2 className="h-5 w-5" />
</Button> </Button>
@@ -327,16 +381,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="flex flex-col sm:flex-row items-start sm:items-center gap-4 justify-between">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-semibold"> <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> </p>
{item.discount_formatted && item.discount_formatted !== "0 TMT" && ( {item.discount_formatted &&
<p className="text-sm font-semibold">{t("discount")} {item.discount_formatted}</p> item.discount_formatted !== "0 TMT" && (
)} <p className="text-sm font-semibold">
{t("discount")} {item.discount_formatted}
</p>
)}
<div className="flex items-center gap-2"> <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"> <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> </span>
</div> </div>
</div> </div>
@@ -346,28 +409,34 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
variant="outline" variant="outline"
size="icon" size="icon"
onClick={handleQuantityDecrease} 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" /> <Minus className="h-4 w-4" />
</Button> </Button>
<div className="w-12 text-center font-semibold relative"> <div className="w-12 text-center font-semibold relative">
{localQuantity} {localQuantity}
{isSyncing && (
<Loader2 className="h-3 w-3 animate-spin absolute -top-1 -right-3 text-blue-500" />
)}
{syncError && ( {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> </div>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
onClick={handleQuantityIncrease} onClick={handleQuantityIncrease}
disabled={localQuantity >= availableStock} // disabled={localQuantity >= availableStock}
className={`rounded-xl cursor-pointer bg-blue-50 ${isSyncing ? 'opacity-70' : ''} ${ className={`rounded-lg cursor-pointer bg-blue-50 ${
localQuantity >= availableStock ? 'opacity-50 cursor-not-allowed' : '' isSyncing ? "opacity-70" : ""
} ${
localQuantity >= availableStock
? "opacity-50 cursor-not-allowed"
: ""
}`} }`}
> >
<Plus className="h-4 w-4 text-[#007AFF]" /> <Plus className="h-4 w-4 text-[#007AFF]" />
@@ -390,16 +459,16 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
{t("stock_limit_title")} {t("stock_limit_title")}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-center text-base pt-2"> <DialogDescription className="text-center text-base pt-2">
{t("stock_limit_message", { {t("stock_limit_message", {
product: item.product.name, product: item.product.name,
stock: availableStock stock: availableStock,
})} })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex justify-center mt-4"> <div className="flex justify-center mt-4">
<Button <Button
onClick={() => setShowStockModal(false)} onClick={() => setShowStockModal(false)}
className="w-full rounded-xl" className="w-full rounded-lg cursor-pointer"
> >
{t("understood")} {t("understood")}
</Button> </Button>
@@ -407,5 +476,5 @@ export default function CartItemCard({ item, onUpdate }: CartItemCardProps) {
</DialogContent> </DialogContent>
</Dialog> </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() { export default function CartItemSkeleton() {
return ( return (
<Card className="p-4 rounded-xl"> <Card className="p-4 shadow-none border">
<div className="flex gap-4"> <div className="flex flex-col sm:flex-row gap-4">
{/* Product Image */} <div className="flex gap-4 flex-1">
<Skeleton className="w-24 h-24 rounded-lg flex-shrink-0 bg-gray-200" /> <Skeleton className="w-[88px] h-[117px] rounded-xl" />
<div className="flex flex-col gap-2 flex-1">
{/* Product Info */} <Skeleton className="h-5 w-3/4" />
<div className="flex-1 space-y-2"> <Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-3/4 bg-gray-200" /> <Skeleton className="h-4 w-1/3" />
<Skeleton className="h-4 w-1/2 bg-gray-200" /> <Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-6 w-20 bg-gray-200 mt-2" /> </div>
</div> </div>
{/* Quantity Controls */} <div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 justify-between">
<div className="flex items-center gap-2"> <div className="space-y-2">
<Skeleton className="w-8 h-8 rounded-lg bg-gray-200" /> <Skeleton className="h-4 w-32" />
<Skeleton className="w-8 h-8 bg-gray-200" /> <Skeleton className="h-4 w-24" />
<Skeleton className="w-8 h-8 rounded-lg bg-gray-200" /> <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>
</div> </div>
</Card> </Card>
) );
} }

View File

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

View File

@@ -13,9 +13,14 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import DeliveryTypeSelector from "./DeliveryTypeSelector";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { DeliveryType, PaymentType, Province } from "@/lib/types/api"; import type {
DeliveryType,
PaymentType,
Province,
OrderDelivery,
} from "@/lib/types/api";
import { useState } from "react";
interface OrderBillingItem { interface OrderBillingItem {
title: string; title: string;
@@ -36,10 +41,11 @@ interface OrderSummaryProps {
billing: OrderBilling; billing: OrderBilling;
}; };
paymentType: PaymentType | null; paymentType: PaymentType | null;
deliveryType: DeliveryType; orderDeliveries: OrderDelivery[];
selectedOrderDelivery: OrderDelivery | null;
selectedRegion: string; selectedRegion: string;
selectedProvince: number | null; selectedProvince: number | null;
note: string; notes: string;
regionGroups: Record<string, Province[]>; regionGroups: Record<string, Province[]>;
availableRegions: string[]; availableRegions: string[];
paymentTypes: PaymentType[]; paymentTypes: PaymentType[];
@@ -50,10 +56,10 @@ interface OrderSummaryProps {
onNameChange: (name: string) => void; onNameChange: (name: string) => void;
onLastNameChange: (lastName: string) => void; onLastNameChange: (lastName: string) => void;
onPaymentTypeChange: (type: PaymentType) => void; onPaymentTypeChange: (type: PaymentType) => void;
onDeliveryTypeChange: (type: DeliveryType) => void; onOrderDeliveryChange: (delivery: OrderDelivery) => void;
onRegionChange: (regionCode: string) => void; onRegionChange: (regionCode: string) => void;
onProvinceChange: (provinceId: number) => void; onProvinceChange: (provinceId: number) => void;
onNoteChange: (note: string) => void; onNoteChange: (notes: string) => void;
onCompleteOrder: () => void; onCompleteOrder: () => void;
isLoading: boolean; isLoading: boolean;
} }
@@ -61,10 +67,11 @@ interface OrderSummaryProps {
export default function OrderSummary({ export default function OrderSummary({
order, order,
paymentType, paymentType,
deliveryType, orderDeliveries,
selectedOrderDelivery,
selectedRegion, selectedRegion,
selectedProvince, selectedProvince,
note, notes,
regionGroups, regionGroups,
availableRegions, availableRegions,
paymentTypes, paymentTypes,
@@ -75,7 +82,7 @@ export default function OrderSummary({
onNameChange, onNameChange,
onLastNameChange, onLastNameChange,
onPaymentTypeChange, onPaymentTypeChange,
onDeliveryTypeChange, onOrderDeliveryChange,
onRegionChange, onRegionChange,
onProvinceChange, onProvinceChange,
onNoteChange, onNoteChange,
@@ -83,17 +90,69 @@ export default function OrderSummary({
isLoading, isLoading,
}: OrderSummaryProps) { }: OrderSummaryProps) {
const t = useTranslations(); const t = useTranslations();
const [showValidation, setShowValidation] = useState(false);
const provincesForSelectedRegion = selectedRegion const provincesForSelectedRegion = selectedRegion
? regionGroups[selectedRegion] || [] ? regionGroups[selectedRegion] || []
: []; : [];
const filteredDeliveries = orderDeliveries.filter((delivery) => {
if (!selectedRegion) return true;
if (selectedRegion === "ag") {
return delivery.name === "standart" || delivery.name === "self_pickup";
} else {
return delivery.name === "region";
}
});
const phoneDigits = phone.replace(/\D/g, "");
const isPhoneValid = phoneDigits.length === 11;
const isFormValid = const isFormValid =
selectedRegion && selectedProvince && paymentType && phone && name; selectedRegion &&
selectedProvince &&
paymentType &&
selectedOrderDelivery &&
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 ( 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 */} {/* Customer Information */}
<div className="mb-6"> <div className="mb-4">
<h3 className="text-lg font-semibold mb-3"> <h3 className="text-lg font-semibold mb-3">
{t("customer_information")} {t("customer_information")}
</h3> </h3>
@@ -107,8 +166,13 @@ export default function OrderSummary({
value={name} value={name}
onChange={(e) => onNameChange(e.target.value)} onChange={(e) => onNameChange(e.target.value)}
placeholder={t("name")} 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>
<div> <div>
<Label className="text-sm font-medium mb-2 block"> <Label className="text-sm font-medium mb-2 block">
@@ -119,8 +183,13 @@ export default function OrderSummary({
value={lastName} value={lastName}
onChange={(e) => onLastNameChange(e.target.value)} onChange={(e) => onLastNameChange(e.target.value)}
placeholder={t("last_name")} 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>
<div> <div>
<Label className="text-sm font-medium mb-2 block"> <Label className="text-sm font-medium mb-2 block">
@@ -129,16 +198,23 @@ export default function OrderSummary({
<Input <Input
type="tel" type="tel"
value={phone} value={phone}
onChange={(e) => onPhoneChange(e.target.value)} onChange={handlePhoneChange}
placeholder={t("phone")} placeholder="+993 61 097651"
className="rounded-xl" 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> </div>
</div> </div>
{/* Payment Type */} {/* Payment Type */}
<div className="mb-6"> <div className="mb-4">
<h3 className="text-lg font-semibold mb-3">{t("payment_type")}</h3> <h3 className="text-lg font-semibold mb-3">{t("payment_type")}</h3>
<div className="flex gap-2"> <div className="flex gap-2">
{paymentTypes.map((type) => ( {paymentTypes.map((type) => (
@@ -147,6 +223,8 @@ export default function OrderSummary({
className={`flex-1 cursor-pointer transition-all ${ className={`flex-1 cursor-pointer transition-all ${
paymentType?.id === type.id paymentType?.id === type.id
? "border-2 border-[#005bff] bg-blue-50" ? "border-2 border-[#005bff] bg-blue-50"
: showValidation && !paymentType
? "border-2 border-red-500"
: "border-2 border-gray-200" : "border-2 border-gray-200"
}`} }`}
onClick={() => onPaymentTypeChange(type)} onClick={() => onPaymentTypeChange(type)}
@@ -163,25 +241,19 @@ export default function OrderSummary({
</Card> </Card>
))} ))}
</div> </div>
{showValidation && !paymentType && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div> </div>
{/* Delivery Type */}
<DeliveryTypeSelector
selectedType={deliveryType}
onSelect={onDeliveryTypeChange}
/>
{/* Region Selection */} {/* Region Selection */}
<div className="mb-6"> <div className="mb-4">
<Label className="text-lg font-semibold mb-3 block"> <Label className="text-lg font-semibold mb-3 block">
{t("choose_region")} {t("choose_region")}
</Label> </Label>
<RadioGroup <RadioGroup
value={selectedRegion} value={selectedRegion}
onValueChange={(value) => { onValueChange={(value) => onRegionChange(value)}
onRegionChange(value);
onProvinceChange(null as any);
}}
className="flex flex-wrap gap-4" className="flex flex-wrap gap-4"
> >
{availableRegions.map((regionCode) => ( {availableRegions.map((regionCode) => (
@@ -189,7 +261,11 @@ export default function OrderSummary({
<RadioGroupItem <RadioGroupItem
value={regionCode} value={regionCode}
id={`region-${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 <Label
htmlFor={`region-${regionCode}`} htmlFor={`region-${regionCode}`}
@@ -200,11 +276,14 @@ export default function OrderSummary({
</div> </div>
))} ))}
</RadioGroup> </RadioGroup>
{showValidation && !selectedRegion && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div> </div>
{/* Province Selection */} {/* Province Selection */}
{selectedRegion && provincesForSelectedRegion.length > 0 && ( {selectedRegion && provincesForSelectedRegion.length > 0 && (
<div className="mb-6"> <div className="mb-4">
<Label className="text-lg font-semibold mb-3 block"> <Label className="text-lg font-semibold mb-3 block">
{t("choose_address")} {t("choose_address")}
</Label> </Label>
@@ -212,7 +291,11 @@ export default function OrderSummary({
value={selectedProvince?.toString() || ""} value={selectedProvince?.toString() || ""}
onValueChange={(value) => onProvinceChange(parseInt(value))} 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")} /> <SelectValue placeholder={t("choose_address")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -223,14 +306,65 @@ export default function OrderSummary({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{showValidation && !selectedProvince && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
)}
{/* Shipping Method */}
{selectedRegion && (
<div className="mb-4">
<h3 className="text-lg font-semibold mb-3">{t("shipping_method")}</h3>
<div className="flex gap-2">
{filteredDeliveries.map((delivery) => (
<Card
key={delivery.name}
className={`flex-1 cursor-pointer py-4 transition-all ${
selectedOrderDelivery?.name === delivery.name
? "border-2 border-[#005bff] bg-blue-50"
: showValidation && !selectedOrderDelivery
? "border-2 border-red-500"
: "border-2 border-gray-200"
}`}
onClick={() => onOrderDeliveryChange(delivery)}
>
<div className="flex items-center flex-col p-4">
<div className="flex flex-col">
<span
className={`text-sm font-medium ${
selectedOrderDelivery?.name === delivery.name
? "text-[#005bff]"
: ""
}`}
>
{t(delivery.name)}
</span>
</div>
<span
className={`text-sm font-bold ${
selectedOrderDelivery?.name === delivery.name
? "text-[#005bff]"
: "text-green-600"
}`}
>
{delivery.price === 0 ? t("free") : `${delivery.price} TMT`}
</span>
</div>
</Card>
))}
</div>
{showValidation && !selectedOrderDelivery && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div> </div>
)} )}
{/* Note */} {/* Note */}
<div className="mb-6"> <div className="mb-4">
<Label className="text-lg font-semibold mb-3 block">{t("note")}</Label> <Label className="text-lg font-semibold mb-3 block">{t("note")}</Label>
<Textarea <Textarea
value={note} value={notes}
onChange={(e) => onNoteChange(e.target.value)} onChange={(e) => onNoteChange(e.target.value)}
className="rounded-xl resize-none" className="rounded-xl resize-none"
rows={3} rows={3}
@@ -263,9 +397,9 @@ export default function OrderSummary({
</div> </div>
<Button <Button
onClick={onCompleteOrder} onClick={handleCompleteOrderClick}
disabled={!isFormValid || isLoading} disabled={isLoading}
className="w-full rounded-xl bg-[#005bff] hover:bg-[#004dcc] h-12 text-lg font-bold disabled:opacity-50" className="w-full rounded-lg cursor-pointer bg-[#005bff] hover:bg-[#004dcc] h-12 text-lg font-bold disabled:opacity-50"
size="lg" size="lg"
> >
{isLoading ? `${t("order")}...` : t("order")} {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,306 @@
import { useQuery, useMutation, useQueryClient, UseQueryOptions } from "@tanstack/react-query" import {
import { apiClient } from "@/lib/api" useQuery,
import type { CartItem } from "@/lib/types/api" useMutation,
useQueryClient,
UseQueryOptions,
} from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import type {
CartItem,
CartResponse,
CreateOrderPayload,
OrderDelivery,
} from "@/lib/types/api";
import { useEffect } from "react";
interface CartResponse { const pendingUpdates = new Map<number, number>();
message: string let updateLock = false;
data: CartItem[]
errorDetails?: string 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());
}
} }
// Transform response to handle HTML/malformed responses export const cartEvents = new CartEventEmitter();
function transformCartResponse(response: any): CartResponse { function transformCartResponse(response: any): CartResponse {
if ( if (
typeof response === "string" && 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 { return {
message: "error", message: "error",
data: [], 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 (typeof response === "object") {
if (response.data) { if (response.data) return response;
return response return { message: "success", data: [] };
}
return { message: "success", data: [] }
} }
if (typeof response === "string") { if (typeof response === "string") {
try { try {
const parsed = JSON.parse(response) return JSON.parse(response);
return parsed } catch {
} catch (error) { return { message: "error", data: [] };
console.error("Failed to parse response:", error)
return { message: "error", data: [] }
} }
} }
return { message: "unknown", data: [] } return { message: "unknown", data: [] };
} }
export function useCart(options?: Partial<UseQueryOptions<CartResponse>>) { export function useCart(options?: Partial<UseQueryOptions<CartResponse>>) {
return useQuery({ const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["cart"], queryKey: ["cart"],
queryFn: async () => { queryFn: async () => {
const response = await apiClient.get("/carts") const response = await apiClient.get("/carts");
return transformCartResponse(response.data) const transformed = transformCartResponse(response.data);
return transformed;
}, },
refetchInterval: 10000, // Increased to 10 seconds (less aggressive) refetchOnMount: false,
refetchOnMount: true, refetchOnWindowFocus: false,
refetchOnWindowFocus: true, // Enable to catch updates on tab focus
refetchOnReconnect: true, refetchOnReconnect: true,
staleTime: 5000, // Data considered fresh for 5 seconds staleTime: Infinity,
gcTime: 1000 * 60 * 5,
retry: 2, retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000), retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
...options, ...options,
}) });
useEffect(() => {
const unsubscribe = cartEvents.subscribe(() => {
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "none",
});
});
return () => {
unsubscribe();
};
}, [queryClient]);
return query;
} }
export function useAddToCart() { export function useAddToCart() {
const queryClient = useQueryClient() const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ productId, quantity = 1 }: { productId: number; quantity?: number }) => { mutationFn: async ({
productId,
quantity = 1,
}: {
productId: number;
quantity?: number;
}) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
product_id: String(productId), product_id: String(productId),
product_quantity: String(quantity), product_quantity: String(quantity),
}) });
const response = await apiClient.post("/carts", params.toString(), { const response = await apiClient.post("/carts", params.toString(), {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
}, },
}) });
if (typeof response.data === "object" && response.data.data) { if (typeof response.data === "object" && response.data.data) {
return response.data return response.data;
} }
if (typeof response.data === "string") { if (typeof response.data === "string") {
try { try {
const parsed = JSON.parse(response.data) return JSON.parse(response.data);
return parsed } catch {
} catch (error) { return { message: "success", data: "Added to cart" };
console.error("Failed to parse add to cart response:", error)
return { message: "success", data: "Added to cart" }
} }
} }
return { message: "success", data: "Added to cart" } return { message: "success", data: "Added to cart" };
}, },
onSuccess: () => { onMutate: async ({ productId, quantity }) => {
// Invalidate but don't refetch immediately (let polling handle it) while (updateLock) {
queryClient.invalidateQueries({ queryKey: ["cart"], refetchType: 'none' }) 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) => { onError: (error, variables, context) => {
console.error("Add to cart error:", error.response?.data?.message || error.message) 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() { export function useRemoveFromCart() {
const queryClient = useQueryClient() const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (productId: number) => { 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(), { const response = await apiClient.patch("/carts", params.toString(), {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
}, },
}) });
if (typeof response.data === "object" && response.data.data) { if (typeof response.data === "object" && response.data.data) {
return response.data.data return response.data.data;
} }
if (typeof response.data === "string") { if (typeof response.data === "string") {
try { try {
const parsed = JSON.parse(response.data) const parsed = JSON.parse(response.data);
return parsed.data || [] return parsed.data || [];
} catch (error) { } catch {
console.error("Failed to parse cart response:", error) return [];
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: () => { onSuccess: () => {
// Immediate refetch after removal queryClient.invalidateQueries({
queryClient.invalidateQueries({ queryKey: ["cart"] }) queryKey: ["cart"],
refetchType: "active",
});
}, },
onError: (error: any) => { });
console.error("Remove from cart error:", error.response?.data?.message || error.message)
},
})
} }
export function useCleanCart() { export function useCleanCart() {
const queryClient = useQueryClient() const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async () => { mutationFn: async () => {
@@ -150,98 +308,205 @@ export function useCleanCart() {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
}, },
}) });
if (typeof response.data === "object" && response.data.data) { if (typeof response.data === "object" && response.data.data) {
return response.data.data return response.data.data;
} }
if (typeof response.data === "string") { if (typeof response.data === "string") {
try { try {
const parsed = JSON.parse(response.data) const parsed = JSON.parse(response.data);
return parsed.data || [] return parsed.data || [];
} catch (error) { } catch {
console.error("Failed to parse cart response:", error) return [];
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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] }) queryClient.invalidateQueries({ queryKey: ["cart"] });
}, },
}) });
} }
export function useUpdateCartItemQuantity() { export function useUpdateCartItemQuantity() {
const queryClient = useQueryClient() const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ productId, quantity }: { productId: number; quantity: number }) => { mutationFn: async ({
productId,
quantity,
}: {
productId: number;
quantity: number;
}) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
product_id: String(productId), product_id: String(productId),
product_quantity: String(quantity), product_quantity: String(quantity),
}) });
const response = await apiClient.post("/carts", params.toString(), { const response = await apiClient.post("/carts", params.toString(), {
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
}, },
timeout: 15000, // 15 second timeout timeout: 15000,
}) });
if (typeof response.data === "object" && response.data.data) { if (typeof response.data === "object" && response.data.data) {
return response.data return response.data;
} }
if (typeof response.data === "string") { if (typeof response.data === "string") {
try { try {
const parsed = JSON.parse(response.data) return JSON.parse(response.data);
return parsed } catch {
} catch (error) { return { message: "success", data: "Updated cart" };
console.error("Failed to parse update cart response:", error)
return { message: "success", data: "Updated cart" }
} }
} }
return { message: "success", data: "Updated cart" } return { message: "success", data: "Updated cart" };
}, },
onSuccess: () => { onMutate: async ({ productId, quantity }) => {
// Invalidate but don't refetch immediately (let optimistic update handle it) while (updateLock) {
queryClient.invalidateQueries({ queryKey: ["cart"], refetchType: 'none' }) 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) => { onError: (error, variables, context) => {
console.error("API update failed:", error.response?.data?.message || error.message) if (context?.previousCart) {
throw error // Re-throw to trigger retry mechanism 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 useOrderDeliveries() {
return useQuery({
queryKey: ["order-deliveries"],
queryFn: async () => {
const response = await apiClient.get<{
message: string;
data: OrderDelivery[];
}>("/order-deliveries");
return response.data.data;
},
});
} }
export function useCreateOrder() { export function useCreateOrder() {
const queryClient = useQueryClient() const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (payload: { mutationFn: async (payload: CreateOrderPayload) => {
customer_name?: string const response = await apiClient.post("/orders", payload);
customer_phone: string return response.data;
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
}, },
onSuccess: () => { onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["cart"] }) // Handle payment URL - check both data.payment_url and data.data.payment_url
queryClient.invalidateQueries({ queryKey: ["orders"] }) const paymentUrl = data?.data?.payment_url || data?.payment_url;
if (paymentUrl) {
window.open(paymentUrl, "_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) => { 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>
)} )}
<FilterSection title={translations.sort}> {/* <FilterSection title={translations.sort}>
<RadioItem <RadioItem
name="sort" name="sort"
checked={priceSort === "none"} checked={priceSort === "none"}
@@ -96,7 +96,7 @@ export default function CategoryFilters({
onChange={() => onPriceSortChange("highToLow")} onChange={() => onPriceSortChange("highToLow")}
label={translations.price_high_to_low} label={translations.price_high_to_low}
/> />
</FilterSection> </FilterSection> */}
<PriceFilter <PriceFilter
title={translations.price} 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} {translations.reset}
</Button> </Button>
</div> </div>

View File

@@ -28,7 +28,7 @@ export default function CategoryFiltersSheet({
<Sheet open={isOpen} onOpenChange={onOpenChange}> <Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button <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" size="lg"
> >
{filterLabel} {filterLabel}
@@ -40,7 +40,7 @@ export default function CategoryFiltersSheet({
<SheetTitle>{filterLabel}</SheetTitle> <SheetTitle>{filterLabel}</SheetTitle>
<button <button
onClick={() => onOpenChange(false)} 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" /> <X className="h-4 w-4" />
<span className="sr-only">{closeLabel}</span> <span className="sr-only">{closeLabel}</span>

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ export default function CollectionFiltersSheet({
<Sheet open={isOpen} onOpenChange={onOpenChange}> <Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button <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" size="lg"
> >
{filterLabel} {filterLabel}
@@ -40,7 +40,7 @@ export default function CollectionFiltersSheet({
<SheetTitle>{filterLabel}</SheetTitle> <SheetTitle>{filterLabel}</SheetTitle>
<button <button
onClick={() => onOpenChange(false)} 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" /> <X className="h-4 w-4" />
<span className="sr-only">{closeLabel}</span> <span className="sr-only">{closeLabel}</span>

View File

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

View File

@@ -5,6 +5,7 @@ import type { Product } from "@/lib/types/api";
interface CollectionProductsGridProps { interface CollectionProductsGridProps {
products: Product[]; products: Product[];
hasMore: boolean; hasMore: boolean;
isFetching?: boolean;
onLoadMore: () => void; onLoadMore: () => void;
translations: { translations: {
loading: string; loading: string;
@@ -16,9 +17,10 @@ export default function CollectionProductsGrid({
products, products,
hasMore, hasMore,
onLoadMore, onLoadMore,
isFetching = false,
translations, translations,
}: CollectionProductsGridProps) { }: CollectionProductsGridProps) {
if (products.length === 0) { if (products.length === 0 && !isFetching) {
return ( return (
<div className="text-center py-8 text-gray-500"> <div className="text-center py-8 text-gray-500">
{translations.no_results} {translations.no_results}
@@ -35,9 +37,17 @@ export default function CollectionProductsGrid({
style={{ overflow: "visible" }} style={{ overflow: "visible" }}
loader={ loader={
<div className="flex justify-center py-4"> <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> </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"> <div className="bg-white rounded-lg grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{products.map((product) => ( {products.map((product) => (
@@ -55,6 +65,18 @@ export default function CollectionProductsGrid({
/> />
))} ))}
</div> </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> </InfiniteScroll>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useRef, useCallback, MouseEvent } from "react"; import { useState, useEffect, useRef, useCallback, MouseEvent } from "react";
import { useRouter } from "next/navigation"; 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 { toast } from "sonner";
import { import {
Carousel, Carousel,
@@ -15,6 +15,13 @@ import {
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useToggleFavorite, useIsFavorite } from "@/lib/hooks"; import { useToggleFavorite, useIsFavorite } from "@/lib/hooks";
import { import {
useAddToCart, useAddToCart,
@@ -65,6 +72,7 @@ export default function ProductCard({
const [current, setCurrent] = useState(0); const [current, setCurrent] = useState(0);
const [localQuantity, setLocalQuantity] = useState(1); const [localQuantity, setLocalQuantity] = useState(1);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [showStockModal, setShowStockModal] = useState(false);
const autoplayRef = useRef<NodeJS.Timeout | null>(null); const autoplayRef = useRef<NodeJS.Timeout | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined); const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
@@ -77,7 +85,6 @@ export default function ProductCard({
const isOutOfStock = stock === 0; const isOutOfStock = stock === 0;
const availableStock = stock || 999; const availableStock = stock || 999;
// Carousel setup
useEffect(() => { useEffect(() => {
if (!api) return; if (!api) return;
setCurrent(api.selectedScrollSnap()); setCurrent(api.selectedScrollSnap());
@@ -88,7 +95,6 @@ export default function ProductCard({
}; };
}, [api]); }, [api]);
// Autoplay
useEffect(() => { useEffect(() => {
if (!api || !hasMultipleImages) return; if (!api || !hasMultipleImages) return;
@@ -101,12 +107,10 @@ export default function ProductCard({
}; };
}, [api, hasMultipleImages]); }, [api, hasMultipleImages]);
// Sync local quantity with cart
useEffect(() => { useEffect(() => {
setLocalQuantity(cartItem?.product_quantity || 1); setLocalQuantity(cartItem?.product_quantity || 1);
}, [cartItem]); }, [cartItem]);
// Server sync function
const syncToServer = useCallback( const syncToServer = useCallback(
async (quantity: number) => { async (quantity: number) => {
if (isRequestInFlightRef.current) { if (isRequestInFlightRef.current) {
@@ -140,7 +144,6 @@ export default function ProductCard({
[id, updateCartMutation, cartItem, refetchCart] [id, updateCartMutation, cartItem, refetchCart]
); );
// Debounced sync
useEffect(() => { useEffect(() => {
if (!isInCart || localQuantity === (cartItem?.product_quantity || 1)) if (!isInCart || localQuantity === (cartItem?.product_quantity || 1))
return; return;
@@ -166,7 +169,7 @@ export default function ProductCard({
{ {
onSuccess: (data) => onSuccess: (data) =>
toast.success( 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"), onError: () => toast.error("Error. Try again"),
} }
@@ -180,13 +183,8 @@ export default function ProductCard({
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// Stock kontrolü
if (localQuantity > availableStock) { if (localQuantity > availableStock) {
toast.error("Insufficient Stock", { setShowStockModal(true);
description: `Only ${availableStock} items available in stock`,
duration: 4000,
});
setLocalQuantity(availableStock);
return; return;
} }
@@ -197,18 +195,17 @@ export default function ProductCard({
productId: id, productId: id,
quantity: localQuantity, quantity: localQuantity,
}); });
await refetchCart(); toast.success(t("added_to_cart"), {
toast.success("Added to cart", { description: `${name} ${t("added_to_cart_description")}`,
description: `${name} has been added to your cart`,
}); });
} catch (error) { } catch (error) {
console.error("Add to cart error:", error); console.error("Add to cart error:", error);
toast.error("Failed to add to cart"); toast.error(t("add_to_cart_failed"));
} finally { } finally {
setIsSyncing(false); setIsSyncing(false);
} }
}, },
[id, name, localQuantity, availableStock, addToCartMutation, refetchCart] [id, name, localQuantity, availableStock, addToCartMutation]
); );
const handleQuantityChange = useCallback( const handleQuantityChange = useCallback(
@@ -221,10 +218,7 @@ export default function ProductCard({
if (newQuantity < 1) return; if (newQuantity < 1) return;
if (newQuantity > availableStock) { if (newQuantity > availableStock) {
toast.error("Stock Limit Reached", { setShowStockModal(true);
description: `Maximum ${availableStock} items available`,
duration: 4000,
});
return; return;
} }
@@ -233,16 +227,24 @@ export default function ProductCard({
[localQuantity, availableStock] [localQuantity, availableStock]
); );
const handleCardClick = (e: MouseEvent<HTMLDivElement>) => { const handleCardClick = useCallback((e: MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
// Prevent navigation if clicking on buttons or interactive elements
if ( if (
target.closest("button") || target.closest("button") ||
target.closest('[data-carousel-control="true"]') target.closest('[data-carousel-control="true"]') ||
target.closest('[role="dialog"]')
) { ) {
e.preventDefault();
e.stopPropagation();
return; return;
} }
// Programmatic navigation
e.preventDefault();
router.push(`/product/${id}`); router.push(`/product/${id}`);
}; }, [router, id]);
const handleNavClick = (e: MouseEvent, action: () => void) => { const handleNavClick = (e: MouseEvent, action: () => void) => {
e.preventDefault(); e.preventDefault();
@@ -251,169 +253,200 @@ export default function ProductCard({
}; };
return ( return (
<div <>
onClick={handleCardClick} <div
className="flex justify-center cursor-pointer" 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 className="relative w-full h-[260px] group"> <Card
<Carousel className="relative gap-2 border-none shadow-none p-0 w-full overflow-hidden rounded-2xl"
opts={{ align: "start", loop: true, watchDrag: false }} style={{ height, maxWidth: width }}
setApi={setApi} >
className="w-full h-full" <div className="relative w-full h-[260px] group">
> <Carousel
<CarouselContent className="h-[260px] ml-0"> opts={{ align: "start", loop: true, watchDrag: false }}
{images.map((image, idx) => ( setApi={setApi}
<CarouselItem key={idx} className="h-[260px] pl-0"> className="w-full h-full"
<div className="h-full flex items-center justify-center"> >
<img <CarouselContent className="h-[260px] ml-0">
src={image} {images.map((image, idx) => (
alt={`${name} - ${idx + 1}`} <CarouselItem key={idx} className="h-[260px] pl-0">
className="max-w-full max-h-full object-contain" <div className="h-full flex items-center justify-center">
draggable="false" <img
/> src={image}
</div> alt={`${name} - ${idx + 1}`}
</CarouselItem> className="max-w-full max-h-full object-contain"
))} draggable="false"
</CarouselContent> />
</div>
</CarouselItem>
))}
</CarouselContent>
{hasMultipleImages && ( {hasMultipleImages && (
<> <>
<CarouselPrevious <CarouselPrevious
data-carousel-control="true" data-carousel-control="true"
className="absolute left-2 opacity-0 group-hover:opacity-100 transition-opacity z-20" className="absolute left-2 opacity-0 group-hover:opacity-100 transition-opacity z-20"
onClick={(e) => handleNavClick(e, () => api?.scrollPrev())} onClick={(e) => handleNavClick(e, () => api?.scrollPrev())}
/> />
<CarouselNext <CarouselNext
data-carousel-control="true" data-carousel-control="true"
className="absolute right-2 opacity-0 group-hover:opacity-100 transition-opacity z-20" className="absolute right-2 opacity-0 group-hover:opacity-100 transition-opacity z-20"
onClick={(e) => handleNavClick(e, () => api?.scrollNext())} onClick={(e) => handleNavClick(e, () => api?.scrollNext())}
/> />
</> </>
)} )}
</Carousel> </Carousel>
<button <button
onClick={handleFavorite} onClick={handleFavorite}
disabled={isFavoriteToggling || isFavoriteLoading} 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" 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 ? ( {isFavoriteLoading ? (
<div className="w-5 h-5 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin" /> <div className="w-5 h-5 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin" />
) : ( ) : (
<Heart <Heart
className={`w-5 h-5 ${ className={`w-5 h-5 ${
isFavorite ? "text-red-500 fill-red-500" : "text-gray-700" isFavorite ? "text-[#005bff] fill-[#005bff]" : "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"
}`} }`}
/> />
))} )}
</div> </button>
)}
{labels.length > 0 && ( {hasMultipleImages && (
<div className="absolute top-2 left-2 flex flex-col gap-1 z-10"> <div className="absolute bottom-2 left-1/2 -translate-x-1/2 z-10 flex gap-1.5">
{labels.map((label, idx) => ( {images.map((_, idx) => (
<Badge <button
key={idx} key={idx}
className="text-white text-[10px] font-bold uppercase rounded-r-md" data-carousel-control="true"
style={{ backgroundColor: label.bg_color }} onClick={(e) => handleNavClick(e, () => api?.scrollTo(idx))}
> className={`h-1.5 rounded-full cursor-pointer transition-all ${
{label.text} 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> </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>
)} )}
</div> </div>
)}
</Card> <CardContent className="p-0 space-y-1">
</div> <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 { useRouter } from "next/navigation";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import ProductCard from "@/features/home/components/ProductCard"; 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 type { Collection } from "@/lib/types/api";
import { Skeleton } from "@/components/ui/skeleton";
type Props = { type Props = {
collection: Collection; collection: Collection;
@@ -22,24 +23,19 @@ export default function CollectionSection({ collection, locale }: Props) {
router.push(`/collections/${collection.slug}`); router.push(`/collections/${collection.slug}`);
}; };
// Hide section if no products
if (!isLoading && (!productsData?.data || productsData.data.length === 0)) {
return null;
}
if (isLoading) { if (isLoading) {
return ( return (
<section className="bg-white rounded-2xl shadow-sm p-6"> <section className="bg-white rounded-2xl shadow-sm p-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse" /> <Skeleton className="h-8 w-48" />
<div className="h-6 w-6 bg-gray-200 rounded-full animate-pulse" /> <Skeleton className="h-6 w-6 rounded-full" />
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="space-y-2"> <div key={i} className="space-y-2">
<div className="w-full h-[260px] bg-gray-200 rounded-xl animate-pulse" /> <Skeleton className="w-full h-[260px] rounded-xl" />
<div className="h-4 bg-gray-200 rounded w-3/4 animate-pulse mx-2" /> <Skeleton className="h-4 w-3/4 mx-2" />
<div className="h-6 bg-gray-200 rounded w-1/2 animate-pulse mx-2" /> <Skeleton className="h-6 w-1/2 mx-2" />
</div> </div>
))} ))}
</div> </div>
@@ -49,6 +45,11 @@ export default function CollectionSection({ collection, locale }: Props) {
if (isError) return null; 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) || []; const displayProducts = productsData?.data.slice(0, 10) || [];
return ( return (
@@ -99,4 +100,4 @@ export default function CollectionSection({ collection, locale }: Props) {
</div> </div>
</section> </section>
); );
} }

View File

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

View File

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

View File

@@ -23,12 +23,14 @@ import {
MapPin, MapPin,
CreditCard, CreditCard,
ShoppingBag, ShoppingBag,
Banknote,
} from "lucide-react"; } from "lucide-react";
import { useToast } from "@/hooks/use-toast"; import { toast } from "sonner";
import { useOrders, useCancelOrder } from "@/lib/hooks"; import { useOrders, useCancelOrder, useOrderDeliveries } from "@/lib/hooks";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type { Order } from "@/lib/types/api"; import type { Order } from "@/lib/types/api";
import EmptyOrders from "./EmptyOrders";
import ErrorPage from "@/components/ErrorPage";
interface OrdersPageClientProps { interface OrdersPageClientProps {
locale: string; locale: string;
} }
@@ -37,10 +39,12 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false); const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
const [orderToCancel, setOrderToCancel] = useState<Order | null>(null); const [orderToCancel, setOrderToCancel] = useState<Order | null>(null);
const [expandedOrders, setExpandedOrders] = useState<Set<number>>(new Set()); const [expandedOrders, setExpandedOrders] = useState<Set<number>>(new Set());
const { toast } = useToast();
const t = useTranslations(); const t = useTranslations();
const { data: orders, isLoading, isError } = useOrders(); const { data: orders, isLoading, isError } = useOrders();
const { data: orderDeliveries, isLoading: deliveriesLoading } =
useOrderDeliveries();
const { mutate: cancelOrder, isPending: isCancellingOrder } = const { mutate: cancelOrder, isPending: isCancellingOrder } =
useCancelOrder(); useCancelOrder();
@@ -66,19 +70,12 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
cancelOrder(orderToCancel.id, { cancelOrder(orderToCancel.id, {
onSuccess: () => { onSuccess: () => {
toast({ toast.success(t("order_cancelled"));
title: t("order_cancelled"),
description: t("order_cancelled_description"),
});
setIsCancelDialogOpen(false); setIsCancelDialogOpen(false);
setOrderToCancel(null); setOrderToCancel(null);
}, },
onError: (error: any) => { onError: (error: any) => {
toast({ toast.error(error.message || t("cancel_order_failed"));
title: t("error"),
description: error.message || t("cancel_order_failed"),
variant: "destructive",
});
}, },
}); });
}, [orderToCancel, cancelOrder, toast, t]); }, [orderToCancel, cancelOrder, toast, t]);
@@ -89,7 +86,7 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
if ( if (
lowerStatus.includes("ожидается") || lowerStatus.includes("ожидается") ||
lowerStatus.includes("pending") || lowerStatus.includes("pending") ||
lowerStatus.includes("garaşlama") lowerStatus.includes("garaşylýar")
) { ) {
return ( return (
<Badge <Badge
@@ -153,50 +150,151 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
const activeOrders = useMemo( const activeOrders = useMemo(
() => orders?.filter((o) => isActiveOrder(o.status)) || [], () => orders?.filter((o) => isActiveOrder(o.status)) || [],
[orders, isActiveOrder] [orders, isActiveOrder],
); );
const completedOrders = useMemo( const completedOrders = useMemo(
() => orders?.filter((o) => !isActiveOrder(o.status)) || [], () => orders?.filter((o) => !isActiveOrder(o.status)) || [],
[orders, isActiveOrder] [orders, isActiveOrder],
); );
const calculateTotal = useCallback((order: Order) => { const getShippingPrice = useCallback(
return order.orderItems.reduce((sum, item) => { (order: Order) => {
return sum + parseFloat(item.unit_price_amount) * item.quantity; if (order.shipping_price !== undefined && order.shipping_price !== null) {
}, 0); return Number(order.shipping_price);
}, []); }
if (isLoading) { if (!orderDeliveries || orderDeliveries.length === 0) return 0;
const methodFromOrder = order.shipping_method.toLowerCase();
// Find delivery method by matching internal name, translated name, or common keywords
const delivery = orderDeliveries.find((d) => {
const internalName = d.name.toLowerCase();
const translatedName = t(internalName).toLowerCase(); // d.name should be used for translation
// Direct match
if (
internalName === methodFromOrder ||
translatedName === methodFromOrder
) {
return true;
}
// Keyword based matching for "region"
if (
(internalName === "region" || internalName === "çapar") &&
(methodFromOrder.includes("welaýat") ||
methodFromOrder.includes("region") ||
methodFromOrder.includes("регион") ||
methodFromOrder.includes("çapar") ||
methodFromOrder.includes("welayat"))
) {
return true;
}
// Keyword based matching for "self_pickup"
if (
internalName === "self_pickup" &&
(methodFromOrder.includes("özüm") ||
methodFromOrder.includes("özüň") ||
methodFromOrder.includes("pickup") ||
methodFromOrder.includes("самовывоз"))
) {
return true;
}
// Keyword based matching for "standart"
if (
internalName === "standart" &&
(methodFromOrder.includes("standart") ||
methodFromOrder.includes("standard"))
) {
return true;
}
return false;
});
return delivery ? Number(delivery.price) : 0;
},
[orderDeliveries, t],
);
const calculateTotal = useCallback(
(order: Order) => {
const itemsTotal = order.orderItems.reduce((sum, item) => {
return sum + parseFloat(item.unit_price_amount) * item.quantity;
}, 0);
const shippingPrice = getShippingPrice(order);
return itemsTotal + shippingPrice;
},
[getShippingPrice],
);
if (isLoading || deliveriesLoading) {
return ( return (
<div className="container mx-auto p-4 min-h-screen"> <div className="mx-auto p-2 lg:p-6 md:p-4 mb-16 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{t("my_orders")}</h1> <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"> <div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-32 rounded-lg" /> <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>
</div> </div>
); );
} }
if (isError) {
return <ErrorPage />;
}
if (isError || !orders || orders.length === 0) { if (isError || !orders || orders.length === 0) {
return ( return <EmptyOrders />;
<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 ( return (
<div className="container mx-auto p-2 lg:p-6 md:p-4 mb-16 min-h-screen"> <div className=" mx-auto p-2 lg:p-6 md:p-4 mb-16 min-h-screen">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold mb-6">{t("my_orders")}</h1> <h1 className="text-xl md:text-2xl lg:text-3xl font-bold mb-6">
{t("my_orders")}
</h1>
<Tabs defaultValue="active" className="w-full"> <Tabs defaultValue="active" className="w-full">
<TabsList className="mb-4 md:mb-6 w-full md:w-fit gap-2 p-0"> <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}) {t("active_orders")} ({activeOrders.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="completed"> <TabsTrigger value="completed">
@@ -221,8 +319,10 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
isCancelling={isCancellingOrder} isCancelling={isCancellingOrder}
getStatusBadge={getStatusBadge} getStatusBadge={getStatusBadge}
calculateTotal={calculateTotal} calculateTotal={calculateTotal}
getShippingPrice={getShippingPrice}
showCancelButton showCancelButton
t={t} t={t}
orderDeliveries={orderDeliveries || []}
/> />
))} ))}
</div> </div>
@@ -244,12 +344,13 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
order={order} order={order}
isExpanded={expandedOrders.has(order.id)} isExpanded={expandedOrders.has(order.id)}
onToggle={() => toggleOrderExpand(order.id)} onToggle={() => toggleOrderExpand(order.id)}
onCancel={handleCancelOrder}
isCancelling={isCancellingOrder} isCancelling={isCancellingOrder}
getStatusBadge={getStatusBadge} getStatusBadge={getStatusBadge}
calculateTotal={calculateTotal} calculateTotal={calculateTotal}
getShippingPrice={getShippingPrice}
showCancelButton={false} showCancelButton={false}
t={t} t={t}
orderDeliveries={orderDeliveries || []}
/> />
))} ))}
</div> </div>
@@ -270,6 +371,7 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
variant="outline" variant="outline"
onClick={() => setIsCancelDialogOpen(false)} onClick={() => setIsCancelDialogOpen(false)}
disabled={isCancellingOrder} disabled={isCancellingOrder}
className="cursor-pointer"
> >
{t("keep_order")} {t("keep_order")}
</Button> </Button>
@@ -277,6 +379,7 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
variant="destructive" variant="destructive"
onClick={confirmCancelOrder} onClick={confirmCancelOrder}
disabled={isCancellingOrder} disabled={isCancellingOrder}
className="cursor-pointer"
> >
{isCancellingOrder ? t("cancelling") : t("cancel_order")} {isCancellingOrder ? t("cancelling") : t("cancel_order")}
</Button> </Button>
@@ -291,12 +394,14 @@ interface CompactOrderCardProps {
order: Order; order: Order;
isExpanded: boolean; isExpanded: boolean;
onToggle: () => void; onToggle: () => void;
onCancel: (order: Order) => void; onCancel?: (order: Order) => void;
isCancelling: boolean; isCancelling: boolean;
getStatusBadge: (status: string) => React.ReactNode; getStatusBadge: (status: string) => React.ReactNode;
calculateTotal: (order: Order) => number; calculateTotal: (order: Order) => number;
getShippingPrice: (order: Order) => number;
showCancelButton: boolean; showCancelButton: boolean;
t: any; t: any;
orderDeliveries: any[];
} }
function CompactOrderCard({ function CompactOrderCard({
@@ -307,12 +412,19 @@ function CompactOrderCard({
isCancelling, isCancelling,
getStatusBadge, getStatusBadge,
calculateTotal, calculateTotal,
getShippingPrice,
showCancelButton, showCancelButton,
t, t,
orderDeliveries,
}: CompactOrderCardProps) { }: CompactOrderCardProps) {
const total = useMemo(() => calculateTotal(order), [calculateTotal, order]); const total = useMemo(() => calculateTotal(order), [calculateTotal, order]);
const itemCount = order.orderItems.length; const itemCount = order.orderItems.length;
const shippingPrice = useMemo(
() => getShippingPrice(order),
[order, getShippingPrice],
);
return ( return (
<Card className="overflow-hidden transition-all py-2 md:py-4 lg:py-6 hover:shadow-md"> <Card className="overflow-hidden transition-all py-2 md:py-4 lg:py-6 hover:shadow-md">
{/* Compact Header - Always Visible */} {/* Compact Header - Always Visible */}
@@ -336,14 +448,13 @@ function CompactOrderCard({
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex flex-col md:flex-row gap-2 "> <div className="flex flex-col md:flex-row gap-2 items-end">
{getStatusBadge(order.status)}
{getStatusBadge(order.status)} <div className="text-right">
<div className="text-right"> <p className="font-bold text-lg text-green-600">
<p className="font-bold text-lg text-green-600"> {total.toFixed(2)} TMT
{total.toFixed(2)} TMT </p>
</p> </div>
</div>
</div> </div>
{isExpanded ? ( {isExpanded ? (
<ChevronUp className="h-5 w-5 text-gray-400" /> <ChevronUp className="h-5 w-5 text-gray-400" />
@@ -359,7 +470,7 @@ function CompactOrderCard({
<div className="border-t bg-white"> <div className="border-t bg-white">
{/* Order Info Grid */} {/* Order Info Grid */}
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4 bg-gray-50"> <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" /> <Calendar className="h-5 w-5 text-blue-500 mt-0.5" />
<div> <div>
<p className="text-sm font-medium text-gray-700"> <p className="text-sm font-medium text-gray-700">
@@ -370,7 +481,7 @@ function CompactOrderCard({
{order.delivery_time} {order.delivery_time}
</p> </p>
</div> </div>
</div> </div> */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<MapPin className="h-5 w-5 text-red-500 mt-0.5" /> <MapPin className="h-5 w-5 text-red-500 mt-0.5" />
@@ -403,6 +514,20 @@ function CompactOrderCard({
<p className="text-sm text-gray-900">{order.shipping_method}</p> <p className="text-sm text-gray-900">{order.shipping_method}</p>
</div> </div>
</div> </div>
<div className="flex items-start gap-3 border-t md:border-t-0 pt-2 md:pt-0">
<Banknote className="h-5 w-5 text-orange-500 mt-0.5" />
<div>
<p className="text-sm font-medium text-gray-700">
{t("shipping_price")}
</p>
<p className="text-sm font-bold text-green-600">
{shippingPrice === 0
? t("free")
: `${shippingPrice.toFixed(2)} TMT`}
</p>
</div>
</div>
</div> </div>
{/* Products List */} {/* Products List */}
@@ -416,7 +541,7 @@ function CompactOrderCard({
key={index} key={index}
className="flex items-center gap-4 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors" 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 <Image
src={ src={
item.product.images_400x400 || item.product.thumbnail item.product.images_400x400 || item.product.thumbnail
@@ -449,7 +574,22 @@ function CompactOrderCard({
{/* Footer with Total and Actions */} {/* Footer with Total and Actions */}
<div className="border-t p-4 bg-gray-50"> <div className="border-t p-4 bg-gray-50">
<div className="flex items-center justify-between mb-3"> <div className="space-y-2 mb-4">
<div className="flex justify-between text-sm text-gray-600">
<span>{t("products")}:</span>
<span>{(total - shippingPrice).toFixed(2)} TMT</span>
</div>
<div className="flex justify-between text-sm text-gray-600">
<span>{t("shipping_method")}:</span>
<span>
{shippingPrice === 0
? t("free")
: `${shippingPrice.toFixed(2)} TMT`}
</span>
</div>
</div>
<div className="flex items-center justify-between mb-3 pt-2 border-t">
<span className="text-base font-semibold text-gray-700"> <span className="text-base font-semibold text-gray-700">
{t("total_price")}: {t("total_price")}:
</span> </span>
@@ -463,10 +603,10 @@ function CompactOrderCard({
variant="destructive" variant="destructive"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onCancel(order); onCancel?.(order);
}} }}
disabled={isCancelling} disabled={isCancelling}
className="w-full" className="w-full cursor-pointer"
> >
{t("cancel_order")} {t("cancel_order")}
</Button> </Button>

View File

@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api"; 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 }) { export function useOrders(options?: { page?: number; perPage?: number }) {
return useQuery<Order[]>({ 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; return response.data.data;
}, },
staleTime: 1000 * 60 * 5, staleTime: 1000 * 60 * 5,
@@ -34,33 +33,33 @@ export function useOrder(id: number | string) {
}); });
} }
export function useCreateOrder() { // export function useCreateOrder() {
const queryClient = useQueryClient(); // const queryClient = useQueryClient();
return useMutation({ // return useMutation({
mutationFn: async (orderData: CreateOrderRequest) => { // mutationFn: async (orderData: CreateOrderRequest) => {
const formData = new URLSearchParams(); // const formData = new URLSearchParams();
Object.entries(orderData).forEach(([key, value]) => { // Object.entries(orderData).forEach(([key, value]) => {
if (value !== null && value !== undefined) { // if (value !== null && value !== undefined) {
formData.append(key, String(value)); // formData.append(key, String(value));
} // }
}); // });
const response = await apiClient.post("/orders", formData, { // const response = await apiClient.post("/orders", formData, {
headers: { // headers: {
"Content-Type": "application/x-www-form-urlencoded", // "Content-Type": "application/x-www-form-urlencoded",
}, // },
}); // });
return response.data; // return response.data;
}, // },
onSuccess: () => { // onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["orders"] }); // queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["cart"] }); // queryClient.invalidateQueries({ queryKey: ["cart"] });
}, // },
}); // });
} // }
export function useCancelOrder() { export function useCancelOrder() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();

View File

@@ -1,6 +1,16 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import Image from "next/image"; import Image from "next/image";
import {
X,
ZoomIn,
ZoomOut,
RotateCw,
RotateCcw,
Maximize2,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useTranslations } from "next-intl";
interface ProductImageGalleryProps { interface ProductImageGalleryProps {
images: string[]; images: string[];
productName: string; productName: string;
@@ -13,10 +23,22 @@ export function ProductImageGallery({
noImageText, noImageText,
}: ProductImageGalleryProps) { }: ProductImageGalleryProps) {
const [selectedImage, setSelectedImage] = useState(0); const [selectedImage, setSelectedImage] = useState(0);
const [isModalOpen, setIsModalOpen] = useState(false);
const [zoom, setZoom] = useState(1);
const [rotation, setRotation] = useState(0);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const t = useTranslations();
const autoplayTimerRef = useRef<NodeJS.Timeout | undefined>(undefined); const autoplayTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const modalImageRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (images.length <= 1) return; setSelectedImage(0);
}, [images]);
useEffect(() => {
if (images.length <= 1 || isModalOpen) return;
const startAutoplay = () => { const startAutoplay = () => {
autoplayTimerRef.current = setInterval(() => { autoplayTimerRef.current = setInterval(() => {
@@ -28,63 +50,417 @@ export function ProductImageGallery({
return () => { return () => {
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current); if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
}; };
}, [images.length]); }, [images.length, isModalOpen]);
useEffect(() => {
if (isModalOpen) {
document.body.style.overflow = "hidden";
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
};
}, [isModalOpen]);
const handleImageSelect = useCallback( const handleImageSelect = useCallback(
(index: number) => { (index: number) => {
setSelectedImage(index); setSelectedImage(index);
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current); if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
if (images.length > 1) { if (images.length > 1 && !isModalOpen) {
autoplayTimerRef.current = setInterval(() => { autoplayTimerRef.current = setInterval(() => {
setSelectedImage((prev) => (prev + 1) % images.length); setSelectedImage((prev) => (prev + 1) % images.length);
}, 3000); }, 3000);
} }
}, },
[images.length] [images.length, isModalOpen],
); );
const openModal = () => {
setIsModalOpen(true);
resetTransform();
};
const closeModal = () => {
setIsModalOpen(false);
resetTransform();
};
const resetTransform = () => {
setZoom(1);
setRotation(0);
setPosition({ x: 0, y: 0 });
};
const handleZoomIn = () => {
setZoom((prev) => Math.min(prev + 0.25, 5));
};
const handleZoomOut = () => {
setZoom((prev) => Math.max(prev - 0.25, 0.5));
};
const handleRotateClockwise = () => {
setRotation((prev) => (prev + 90) % 360);
};
const handleRotateCounterClockwise = () => {
setRotation((prev) => (prev - 90 + 360) % 360);
};
const handleMouseDown = (e: React.MouseEvent | React.TouchEvent) => {
if (zoom > 1) {
setIsDragging(true);
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
setDragStart({
x: clientX - position.x,
y: clientY - position.y,
});
}
};
const handleMouseMove = (e: React.MouseEvent | React.TouchEvent) => {
if (isDragging && zoom > 1) {
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
setPosition({
x: clientX - dragStart.x,
y: clientY - dragStart.y,
});
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
if (e.deltaY < 0) {
handleZoomIn();
} else {
handleZoomOut();
}
};
const handleModalImageChange = (direction: "prev" | "next") => {
if (direction === "next") {
setSelectedImage((prev) => (prev + 1) % images.length);
} else {
setSelectedImage((prev) => (prev - 1 + images.length) % images.length);
}
resetTransform();
};
return ( return (
<div className="flex-1 max-w-2xl"> <>
<div className="relative"> <div className="w-full lg:flex-1 max-w-2xl">
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-white"> <div className="relative">
{images.length > 0 ? ( <div
<Image className="relative aspect-square w-full rounded-xl md:rounded-2xl overflow-hidden bg-gradient-to-br from-gray-50 to-gray-100 cursor-pointer group shadow-sm hover:shadow-md transition-all"
src={images[selectedImage]} onClick={openModal}
alt={productName} >
fill {images.length > 0 && images[selectedImage] ? (
className="object-contain" <>
priority <Image
/> src={images[selectedImage]}
) : ( alt={productName}
<div className="flex items-center justify-center h-full text-gray-400"> fill
{noImageText} sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-contain transition-transform group-hover:scale-105"
priority
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/20 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<div className="bg-white/90 backdrop-blur-sm rounded-full p-2 md:p-3 transform translate-y-4 group-hover:translate-y-0 transition-transform">
<Maximize2 className="w-4 h-4 md:w-5 md:h-5 text-gray-800" />
</div>
</div>
</>
) : (
<div className="flex items-center justify-center h-full text-gray-400 text-sm md:text-base">
{noImageText}
</div>
)}
</div>
{images.length > 1 && (
<div className="mt-4 flex gap-2 overflow-x-auto pb-2">
{images.map((image, index) => (
<button
key={index}
onClick={() => handleImageSelect(index)}
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"
}`}
>
<Image
src={image}
alt={`${productName} ${index + 1}`}
fill
className="object-cover"
/>
</button>
))}
</div> </div>
)} )}
</div> </div>
</div>
{images.length > 1 && ( {/* Modal */}
<div className="mt-4 flex gap-2 overflow-x-auto pb-2"> {isModalOpen && (
{images.map((image, index) => ( <div className="fixed inset-0 z-99 bg-gradient-to-br from-gray-900/95 via-gray-800/95 to-gray-900/95 backdrop-blur-xl flex flex-col">
{/* Top Bar */}
<div className="absolute top-0 left-0 right-0 p-3 md:p-4 z-20 bg-gradient-to-b from-black/20 to-transparent">
<div className="max-w-7xl mx-auto flex items-center justify-between">
<div className="flex items-center gap-2 md:gap-3 min-w-0 flex-1">
<div className="w-1 h-4 md:h-6 bg-blue-500 rounded-full shrink-0" />
<span className="text-white font-medium text-sm md:text-base truncate">
{productName}
</span>
</div>
<button <button
key={index} onClick={closeModal}
onClick={() => handleImageSelect(index)} className="p-2 md:p-2.5 bg-white/10 hover:bg-white/20 rounded-lg md:rounded-xl transition-all backdrop-blur-sm border border-white/10 shrink-0 ml-2"
className={`relative w-16 h-16 flex-shrink-0 rounded overflow-hidden border-2 transition-all ${ aria-label="Close"
selectedImage === index >
? "border-primary ring-2 ring-primary/20" <X className="w-4 h-4 md:w-5 md:h-5 text-white" />
: "border-gray-200 hover:border-gray-300" </button>
}`} </div>
</div>
{/* Main Image Area */}
<div className="flex-1 flex items-center justify-center relative px-2 md:px-16 lg:px-20">
{/* Left Arrow - Desktop */}
{images.length > 1 && (
<button
onClick={() => handleModalImageChange("prev")}
className="hidden md:flex absolute left-3 lg:left-6 p-2.5 md:p-3 bg-white/10 hover:bg-white/20 rounded-xl md:rounded-2xl transition-all backdrop-blur-md border border-white/10 hover:scale-110 z-10 group"
aria-label="Previous image"
>
<ChevronLeft className="w-5 h-5 md:w-6 md:h-6 text-white" />
</button>
)}
{/* Image Container */}
<div
ref={modalImageRef}
className="w-full h-full flex items-center justify-center overflow-hidden"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleMouseDown}
onTouchMove={handleMouseMove}
onTouchEnd={handleMouseUp}
onWheel={handleWheel}
style={{
cursor:
zoom > 1 ? (isDragging ? "grabbing" : "grab") : "default",
}}
>
<div
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${zoom}) rotate(${rotation}deg)`,
transition: isDragging
? "none"
: "transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
}}
className="relative w-[90vw] h-[60vh] md:w-[75vw] md:h-[70vh]"
> >
<Image <Image
src={image} src={images[selectedImage]}
alt={`${productName} ${index + 1}`} alt={productName}
fill fill
className="object-cover" className="object-contain pointer-events-none select-none"
priority
draggable={false}
/> />
</div>
</div>
{/* Right Arrow - Desktop */}
{images.length > 1 && (
<button
onClick={() => handleModalImageChange("next")}
className="hidden md:flex absolute right-3 lg:right-6 p-2.5 md:p-3 bg-white/10 hover:bg-white/20 rounded-xl md:rounded-2xl transition-all backdrop-blur-md border border-white/10 hover:scale-110 z-10 group"
aria-label="Next image"
>
<ChevronRight className="w-5 h-5 md:w-6 md:h-6 text-white" />
</button> </button>
))} )}
</div> </div>
)}
</div> {/* Bottom Control Bar */}
</div> <div className="bg-gradient-to-t from-black/40 via-black/20 to-transparent backdrop-blur-xl border-t border-white/10">
<div className="max-w-7xl mx-auto px-3 md:px-6 py-3 md:py-4">
{/* Mobile Layout */}
<div className="md:hidden flex flex-col gap-2">
{/* Row 1: Navigation */}
{images.length > 1 && (
<div className="flex items-center justify-between gap-2">
<button
onClick={() => handleModalImageChange("prev")}
className="flex-1 p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all border border-white/10"
aria-label="Previous"
>
<ChevronLeft className="w-5 h-5 text-white mx-auto" />
</button>
<div className="px-4 py-2 bg-white/10 backdrop-blur-md rounded-lg border border-white/10">
<span className="text-white text-sm font-medium whitespace-nowrap">
{selectedImage + 1} / {images.length}
</span>
</div>
<button
onClick={() => handleModalImageChange("next")}
className="flex-1 p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all border border-white/10"
aria-label="Next"
>
<ChevronRight className="w-5 h-5 text-white mx-auto" />
</button>
</div>
)}
{/* Row 2: Zoom & Rotate */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10 flex-1">
<button
onClick={handleZoomOut}
className="p-2 hover:bg-white/20 rounded-md transition-all flex-1"
aria-label="Zoom out"
>
<ZoomOut className="w-4 h-4 text-white mx-auto" />
</button>
<div className="px-2 py-1 bg-white/10 rounded text-center min-w-[50px]">
<span className="text-white text-xs font-medium">
{Math.round(zoom * 100)}%
</span>
</div>
<button
onClick={handleZoomIn}
className="p-2 hover:bg-white/20 rounded-md transition-all flex-1"
aria-label="Zoom in"
>
<ZoomIn className="w-4 h-4 text-white mx-auto" />
</button>
</div>
<div className="flex items-center gap-1 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10">
<button
onClick={handleRotateCounterClockwise}
className="p-2 hover:bg-white/20 rounded-md transition-all"
aria-label="Rotate counter-clockwise"
>
<RotateCcw className="w-4 h-4 text-white" />
</button>
<button
onClick={handleRotateClockwise}
className="p-2 hover:bg-white/20 rounded-md transition-all"
aria-label="Rotate clockwise"
>
<RotateCw className="w-4 h-4 text-white" />
</button>
</div>
<button
onClick={resetTransform}
className="px-3 py-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all text-white text-xs font-medium border border-white/10"
aria-label="Reset view"
>
{t("reset")}
</button>
</div>
</div>
{/* Desktop Layout */}
<div className="hidden md:flex items-center justify-center gap-2">
<button
onClick={() => handleModalImageChange("prev")}
disabled={images.length <= 1}
className="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all disabled:opacity-30 disabled:cursor-not-allowed border border-white/10"
aria-label="Previous"
>
<ChevronLeft className="w-4 h-4 text-white" />
</button>
<div className="flex items-center gap-1.5 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10">
<button
onClick={handleZoomOut}
className="p-2 hover:bg-white/20 rounded-md transition-all"
aria-label="Zoom out"
>
<ZoomOut className="w-4 h-4 text-white" />
</button>
<div className="px-3 py-1 bg-white/10 rounded min-w-[60px] text-center">
<span className="text-white text-sm font-medium">
{Math.round(zoom * 100)}%
</span>
</div>
<button
onClick={handleZoomIn}
className="p-2 hover:bg-white/20 rounded-md transition-all"
aria-label="Zoom in"
>
<ZoomIn className="w-4 h-4 text-white" />
</button>
</div>
<div className="w-px h-8 bg-white/20" />
<div className="flex items-center gap-1.5 bg-white/10 backdrop-blur-md rounded-lg p-1 border border-white/10">
<button
onClick={handleRotateCounterClockwise}
className="p-2 hover:bg-white/20 rounded-md transition-all"
aria-label="Rotate counter-clockwise"
>
<RotateCcw className="w-4 h-4 text-white" />
</button>
<button
onClick={handleRotateClockwise}
className="p-2 hover:bg-white/20 rounded-md transition-all"
aria-label="Rotate clockwise"
>
<RotateCw className="w-4 h-4 text-white" />
</button>
</div>
<button
onClick={resetTransform}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all text-white text-sm font-medium border border-white/10"
aria-label="Reset view"
>
Reset
</button>
<div className="w-px h-8 bg-white/20" />
<button
onClick={() => handleModalImageChange("next")}
disabled={images.length <= 1}
className="p-2 bg-white/10 hover:bg-white/20 rounded-lg transition-all disabled:opacity-30 disabled:cursor-not-allowed border border-white/10"
aria-label="Next"
>
<ChevronRight className="w-4 h-4 text-white" />
</button>
{images.length > 1 && (
<>
<div className="w-px h-8 bg-white/20" />
<div className="px-4 py-2 bg-white/10 backdrop-blur-md rounded-lg border border-white/10">
<span className="text-white text-sm font-medium">
{selectedImage + 1} / {images.length}
</span>
</div>
</>
)}
</div>
</div>
</div>
</div>
)}
</>
); );
} }

View File

@@ -8,6 +8,7 @@ interface ProductProperty {
} }
interface ProductInfoCardProps { interface ProductInfoCardProps {
name: string;
brandName?: string; brandName?: string;
stock?: number; stock?: number;
barcode?: string; barcode?: string;
@@ -20,6 +21,7 @@ interface ProductInfoCardProps {
} }
export function ProductInfoCard({ export function ProductInfoCard({
name,
brandName, brandName,
stock, stock,
barcode, barcode,
@@ -30,27 +32,10 @@ export function ProductInfoCard({
reviewsCount, reviewsCount,
t, t,
}: ProductInfoCardProps) { }: 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 ( return (
<div className="flex-1 space-y-6 bg-white"> <div className="flex-1 space-y-6 bg-white">
<Card className="p-4 rounded-xl border-gray-200"> <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"> <div className="space-y-3">
{brandName && ( {brandName && (
<> <>
@@ -62,7 +47,7 @@ export function ProductInfoCard({
</> </>
)} )}
{stock !== undefined && ( {/* {stock !== undefined && (
<> <>
<div className="flex justify-between items-center py-2"> <div className="flex justify-between items-center py-2">
<span className="text-gray-500">{t("stock")}</span> <span className="text-gray-500">{t("stock")}</span>
@@ -94,7 +79,7 @@ export function ProductInfoCard({
</div> </div>
<Separator /> <Separator />
</> </>
)} )} */}
{colour && ( {colour && (
<> <>
@@ -126,7 +111,7 @@ export function ProductInfoCard({
</Card> </Card>
{description && ( {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"> <h3 className="text-xl font-semibold mb-3">
{t("product_description")} {t("product_description")}
</h3> </h3>
@@ -138,4 +123,4 @@ export function ProductInfoCard({
)} )}
</div> </div>
); );
} }

View File

@@ -12,6 +12,7 @@ import {
useUpdateCartItemQuantity, useUpdateCartItemQuantity,
useRemoveFromCart, useRemoveFromCart,
useCart, useCart,
cartEvents,
} from "@/features/cart/hooks/useCart"; } from "@/features/cart/hooks/useCart";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -22,7 +23,10 @@ import { ProductReviewsSection } from "./ProductReviewsSection";
import { RelatedProductsSection } from "./RelatedProductsSection"; import { RelatedProductsSection } from "./RelatedProductsSection";
import { ReviewModal } from "./ReviewModal"; import { ReviewModal } from "./ReviewModal";
import { StockLimitModal } from "./StockLimitModal"; import { StockLimitModal } from "./StockLimitModal";
import {
useIsFavorite,
useToggleFavorite,
} from "@/features/favorites/hooks/useFavorites";
interface ProductDetailProps { interface ProductDetailProps {
slug: string; slug: string;
} }
@@ -35,13 +39,19 @@ interface PendingUpdate {
retryCount: number; retryCount: number;
} }
// const DEBUG = true
// const log = (...args: any[]) => {
// if (DEBUG) console.log("[ProductPage]", ...args)
// }
export default function ProductPageContent({ slug }: ProductDetailProps) { export default function ProductPageContent({ slug }: ProductDetailProps) {
const [localQuantity, setLocalQuantity] = useState(1); const [localQuantity, setLocalQuantity] = useState(1);
const [isFavorite, setIsFavorite] = useState(false);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState(false); const [syncError, setSyncError] = useState(false);
const [showStockModal, setShowStockModal] = useState(false); const [showStockModal, setShowStockModal] = useState(false);
const [showReviewModal, setShowReviewModal] = useState(false); const [showReviewModal, setShowReviewModal] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const t = useTranslations(); const t = useTranslations();
@@ -52,6 +62,8 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
const retryTimerRef = useRef<NodeJS.Timeout | undefined>(undefined); const retryTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const syncToServerRef = useRef<((quantity: number) => void) | null>(null); const syncToServerRef = useRef<((quantity: number) => void) | null>(null);
const retrySyncRef = 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 { const {
data: product, data: product,
@@ -59,9 +71,24 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
error, error,
refetch: refetchProduct, refetch: refetchProduct,
} = useProductsBySlug(slug); } = 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, { const { data: relatedProducts } = useRelatedProducts(product?.id || 0, {
enabled: !!product?.id, enabled: !!product?.id,
}); });
@@ -71,32 +98,71 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
const removeFromCartMutation = useRemoveFromCart(); const removeFromCartMutation = useRemoveFromCart();
const submitReviewMutation = useSubmitReview(); const submitReviewMutation = useSubmitReview();
const cartItem = useMemo( const cartItem = useMemo(() => {
() => cartData?.data?.find((item: any) => item.product?.id === product?.id), const item = cartData?.data?.find(
[cartData, product] (item: any) => item.product?.id === product?.id,
); );
return item;
}, [cartData, product, isInitialized]);
const isInCart = !!cartItem; const isInCart = !!cartItem;
const availableStock = product?.stock || 0; const availableStock = product?.stock || 0;
const imageUrls = useMemo( const imageUrls = useMemo(() => {
() =>
product?.media?.map( if (!product?.media || product.media.length === 0) {
(m) => m.images_800x800 || m.images_720x720 || m.thumbnail return [];
) || [], }
[product]
); const urls = product.media
.map((m) => {
const url = m.images_800x800 || m.images_720x720 || m.images_400x400 || m.thumbnail;
return url;
})
.filter(Boolean);
return urls;
}, [product]);
// ✅ CORRECT - Use reviews from product data
const reviews = useMemo(() => product?.reviews_resources || [], [product]); const reviews = useMemo(() => product?.reviews_resources || [], [product]);
const averageRating = useMemo( const averageRating = useMemo(
() => (product?.reviews?.rating ? parseFloat(product.reviews.rating) : 0), () =>
[product] 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(() => { useEffect(() => {
if (!product?.id || isInitialized) return;
if (cartItem?.product_quantity) { 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]); }, [cartItem]);
const savePendingUpdate = useCallback( const savePendingUpdate = useCallback(
@@ -114,13 +180,13 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
}; };
sessionStorage.setItem( sessionStorage.setItem(
PENDING_PRODUCT_UPDATES_KEY, PENDING_PRODUCT_UPDATES_KEY,
JSON.stringify(pending) JSON.stringify(pending),
); );
} catch (error) { } catch (error) {
console.error("Failed to save pending update:", error); console.error("Failed to save pending update:", error);
} }
}, },
[product?.id] [product?.id],
); );
const clearPendingUpdate = useCallback(() => { const clearPendingUpdate = useCallback(() => {
@@ -135,7 +201,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
} else { } else {
sessionStorage.setItem( sessionStorage.setItem(
PENDING_PRODUCT_UPDATES_KEY, PENDING_PRODUCT_UPDATES_KEY,
JSON.stringify(pending) JSON.stringify(pending),
); );
} }
} }
@@ -152,6 +218,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
if (retryCount >= maxRetries) { if (retryCount >= maxRetries) {
setSyncError(true); setSyncError(true);
setIsSyncing(false); setIsSyncing(false);
shouldSyncFromCartRef.current = true;
toast.error(t("error"), { toast.error(t("error"), {
description: t("update_quantity_failed"), description: t("update_quantity_failed"),
}); });
@@ -165,7 +232,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
syncToServerRef.current?.(quantity); syncToServerRef.current?.(quantity);
}, delay); }, delay);
}, },
[t] [t],
); );
retrySyncRef.current = retrySync; retrySyncRef.current = retrySync;
@@ -199,11 +266,8 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
}); });
} }
isRequestInFlightRef.current = false;
setIsSyncing(false);
retryCountRef.current = 0; retryCountRef.current = 0;
clearPendingUpdate(); clearPendingUpdate();
await refetchCart();
if (pendingQuantityRef.current !== null) { if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current; const nextQuantity = pendingQuantityRef.current;
@@ -211,15 +275,15 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100); setTimeout(() => syncToServerRef.current?.(nextQuantity), 100);
} }
} catch (error) { } catch (error) {
console.error("Sync failed:", error); setLocalQuantity(cartItem?.product_quantity || 1);
isRequestInFlightRef.current = false; toast.error(t("failed_to_update_quantity"), {
description: "Please try again",
if (retryCountRef.current >= 3) { });
setLocalQuantity(cartItem?.product_quantity || 1);
clearPendingUpdate();
}
retrySyncRef.current?.(quantity); retrySyncRef.current?.(quantity);
} finally {
isRequestInFlightRef.current = false;
setIsSyncing(false);
} }
}, },
[ [
@@ -230,57 +294,22 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
removeFromCartMutation, removeFromCartMutation,
cartItem, cartItem,
clearPendingUpdate, clearPendingUpdate,
refetchCart,
t, t,
] ],
); );
syncToServerRef.current = syncToServer; 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(() => { useEffect(() => {
if (!isInCart || !product?.id) return; if (!isInCart || !product?.id) return;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (localQuantity === (cartItem?.product_quantity || 1)) { if (localQuantity === (cartItem?.product_quantity || 1)) {
return; return;
} }
savePendingUpdate(localQuantity); if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => { debounceTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(localQuantity); syncToServerRef.current?.(localQuantity);
@@ -291,7 +320,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
clearTimeout(debounceTimerRef.current); clearTimeout(debounceTimerRef.current);
} }
}; };
}, [localQuantity, isInCart, product?.id, cartItem, savePendingUpdate]); }, [localQuantity, isInCart, product?.id, cartItem?.product_quantity]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -303,7 +332,13 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
const handleAddToCart = useCallback(async () => { const handleAddToCart = useCallback(async () => {
if (!product?.id) return; if (!product?.id) return;
if (localQuantity > availableStock) {
setShowStockModal(true);
return;
}
setIsSyncing(true); setIsSyncing(true);
shouldSyncFromCartRef.current = false;
try { try {
await addToCartMutation.mutateAsync({ await addToCartMutation.mutateAsync({
@@ -311,7 +346,12 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
quantity: localQuantity, quantity: localQuantity,
}); });
await refetchCart(); lastSyncedQuantityRef.current = localQuantity;
setTimeout(() => {
shouldSyncFromCartRef.current = true;
}, 150);
setIsSyncing(false); setIsSyncing(false);
toast.success(t("added_to_cart"), { toast.success(t("added_to_cart"), {
@@ -320,28 +360,67 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
} catch (error) { } catch (error) {
console.error("Add to cart error:", error); console.error("Add to cart error:", error);
setIsSyncing(false); setIsSyncing(false);
shouldSyncFromCartRef.current = true;
toast.error(t("error"), { toast.error(t("error"), {
description: t("add_to_cart_failed"), description: t("add_to_cart_failed"),
}); });
} }
}, [product, localQuantity, addToCartMutation, refetchCart, t]); }, [product, localQuantity, availableStock, addToCartMutation, t]);
const handleQuantityIncrease = useCallback(() => { const handleQuantityIncrease = useCallback(() => {
if (localQuantity >= availableStock) { if (localQuantity >= availableStock) {
setShowStockModal(true); setShowStockModal(true);
return; return;
} }
setLocalQuantity((prev) => prev + 1); setLocalQuantity((prev) => {
const newVal = prev + 1;
return newVal;
});
}, [localQuantity, availableStock]); }, [localQuantity, availableStock]);
const handleQuantityDecrease = useCallback(() => { const handleQuantityDecrease = useCallback(() => {
if (localQuantity <= 0) return; if (localQuantity <= 0) return;
setLocalQuantity((prev) => prev - 1); setLocalQuantity((prev) => {
const newVal = prev - 1;
return newVal;
});
}, [localQuantity]); }, [localQuantity]);
const handleToggleFavorite = useCallback(() => { const handleToggleFavorite = useCallback(
setIsFavorite(!isFavorite); (e?: React.MouseEvent<HTMLButtonElement>) => {
}, [isFavorite]); 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( const handleSubmitReview = useCallback(
async (rating: number, text: string) => { async (rating: number, text: string) => {
@@ -360,9 +439,8 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
source: "site", source: "site",
}); });
// ✅ Refetch product to get updated reviews
await refetchProduct(); await refetchProduct();
toast.success("Review submitted successfully!"); toast.success("Review submitted successfully!");
setShowReviewModal(false); setShowReviewModal(false);
} catch (error) { } catch (error) {
@@ -371,12 +449,12 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
}); });
} }
}, },
[product?.id, submitReviewMutation, refetchProduct, t] [product?.id, submitReviewMutation, refetchProduct, t],
); );
const loadingSkeleton = useMemo( 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 flex-col lg:flex-row gap-8">
<div className="flex-1 max-w-2xl"> <div className="flex-1 max-w-2xl">
<Skeleton className="aspect-square w-full rounded-2xl" /> <Skeleton className="aspect-square w-full rounded-2xl" />
@@ -393,14 +471,14 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
</div> </div>
</div> </div>
), ),
[] [],
); );
if (productLoading) return loadingSkeleton; if (productLoading) return loadingSkeleton;
if (error || !product) { if (error || !product) {
return ( 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"> <h2 className="text-2xl font-bold text-red-600">
{t("product_not_found")} {t("product_not_found")}
</h2> </h2>
@@ -422,10 +500,11 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
/> />
<ProductInfoCard <ProductInfoCard
brandName={product.brand?.name} name={product.name}
brandName={product.brand?.name ?? undefined}
stock={product.stock} stock={product.stock}
barcode={product.barcode} barcode={product.barcode}
colour={product.colour} colour={product.colour ?? undefined}
properties={product.properties} properties={product.properties}
description={product.description} description={product.description}
averageRating={averageRating} averageRating={averageRating}
@@ -435,7 +514,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
<ProductPurchaseCard <ProductPurchaseCard
price={product.price_amount} price={product.price_amount}
oldPrice={product.old_price_amount} oldPrice={product.old_price_amount ?? undefined}
isInCart={isInCart} isInCart={isInCart}
localQuantity={localQuantity} localQuantity={localQuantity}
availableStock={availableStock} availableStock={availableStock}
@@ -459,7 +538,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
onWriteReview={() => setShowReviewModal(true)} onWriteReview={() => setShowReviewModal(true)}
/> />
<RelatedProductsSection products={relatedProducts || []} /> <RelatedProductsSection products={transformedRelatedProducts} />
</div> </div>
<StockLimitModal <StockLimitModal
@@ -478,4 +557,4 @@ export default function ProductPageContent({ slug }: ProductDetailProps) {
/> />
</> </>
); );
} }

View File

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

View File

@@ -3,6 +3,7 @@ import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "next-intl";
interface Review { interface Review {
id: number; id: number;
@@ -41,21 +42,23 @@ export function ProductReviewsSection({
); );
}; };
const t= useTranslations();
return ( return (
<Card className="p-6 rounded-xl"> <Card className="p-6 rounded-xl">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center ">
<div> <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"> <div className="flex items-center gap-2 mt-2">
{renderStars(Math.round(averageRating))} {renderStars(Math.round(averageRating))}
<span className="text-sm text-gray-600"> {/* <span className="text-sm text-gray-600">
{averageRating.toFixed(1)} out of 5 {averageRating.toFixed(1)} out of 5
</span> </span> */}
</div> </div>
</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" /> <Send className="mr-2 h-4 w-4" />
Write Review {t("write_review")}
</Button> </Button>
</div> </div>
@@ -83,7 +86,7 @@ export function ProductReviewsSection({
</div> </div>
) : ( ) : (
<div className="text-center py-8 text-gray-500"> <div className="text-center py-8 text-gray-500">
No reviews yet. Be the first to review this product! {t("no_reviews")}
</div> </div>
)} )}
</Card> </Card>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -19,9 +19,11 @@
"loading": "Загрузка...", "loading": "Загрузка...",
"all_collections_loaded": "Все коллекции загружены" "all_collections_loaded": "Все коллекции загружены"
}, },
"category": "Категория", "category": "Категория",
"checkout": "Оформить заказ", "checkout": "Оформить заказ",
"price_label": "Цена:", "price_label": "Цена:",
"shipping_price": "Цена доставки",
"extra_price": "Доп. цена:", "extra_price": "Доп. цена:",
"discount": "Скидка:", "discount": "Скидка:",
"total_price": "Общая цена:", "total_price": "Общая цена:",
@@ -33,9 +35,6 @@
"address_search": "Поиск адреса", "address_search": "Поиск адреса",
"address": "Адрес", "address": "Адрес",
"first_name": "Имя", "first_name": "Имя",
"building": "Дом",
"floor": "Этаж",
"apartment": "Кв",
"save": "Сохранить", "save": "Сохранить",
"enter_phone": "Введите свой номер телефона", "enter_phone": "Введите свой номер телефона",
"code_will_be_sent": "Мы вышлем вам код", "code_will_be_sent": "Мы вышлем вам код",
@@ -48,6 +47,10 @@
"delivery_type": "Тип доставки", "delivery_type": "Тип доставки",
"delivery": "Доставка", "delivery": "Доставка",
"pickup": "Самовывоз", "pickup": "Самовывоз",
"standart": "Стандартная",
"self_pickup": "Самовывоз",
"region": "Çapar ugrat",
"free": "Бесплатно",
"payment_type": "Тип оплаты", "payment_type": "Тип оплаты",
"cash": "Наличные", "cash": "Наличные",
"card": "Карта", "card": "Карта",
@@ -84,7 +87,7 @@
"become_seller": "Стать продавцом", "become_seller": "Стать продавцом",
"choose_region": "Выберите регион", "choose_region": "Выберите регион",
"choose_or_enter_address": "Выберите или введите свой адрес", "choose_or_enter_address": "Выберите или введите свой адрес",
"note": "Заметка", "note": "Укажите подробнее свой адрес",
"seller_application_form": "Форма подачи заявления на открытие магазина", "seller_application_form": "Форма подачи заявления на открытие магазина",
"phone": "Телефон", "phone": "Телефон",
"unit_price": "Цена за 1 шт.:", "unit_price": "Цена за 1 шт.:",
@@ -102,6 +105,8 @@
"empty_favorites": "У вас пока нет избранных товаров", "empty_favorites": "У вас пока нет избранных товаров",
"removed_from_favorites": "Товар удален из избранного", "removed_from_favorites": "Товар удален из избранного",
"added_to_cart": "Товар добавлен в корзину", "added_to_cart": "Товар добавлен в корзину",
"failed_to_update_quantity": "Количество не удалось обновить",
"removed_from_cart": "Товар удален из корзины",
"error": "Произошла ошибка", "error": "Произошла ошибка",
"out_of_stock": "Нет в наличии", "out_of_stock": "Нет в наличии",
"personal_info": "Личная информация", "personal_info": "Личная информация",
@@ -163,5 +168,46 @@
"enter_address": "Введите адрес", "enter_address": "Введите адрес",
"save_changes": "Сохранить изменения", "save_changes": "Сохранить изменения",
"saving": "Сохранение...", "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": "Файл загрузить",
"register_title": "Регистрация",
"register_description": "Заполните ваши данные",
"register_button": "Зарегистрироваться",
"name_required": "Введите ваше имя",
"address_required": "Введите ваш адрес",
"name_placeholder": "Ваше имя",
"address_placeholder": "Ваш адрес (например: Теджен)",
"checking": "Проверка...",
"registering": "Регистрация...",
"registration_success": "Регистрация успешна! Введите код",
"verify_title": "Введите код",
"verify_description": "Введите код, отправленный на ваш телефон",
"back": "Назад"
} }

View File

@@ -1,7 +1,7 @@
{ {
"common": { "common": {
"categories": "Bölümler", "categories": "Bölümler",
"products": "Azyk harytlary", "products": "Harytlar",
"catalog": "Katalog", "catalog": "Katalog",
"search": "Haryt gözleg", "search": "Haryt gözleg",
"orders": "Sargytlar", "orders": "Sargytlar",
@@ -22,6 +22,7 @@
"category": "Bölümler", "category": "Bölümler",
"checkout": "Sargyt et", "checkout": "Sargyt et",
"price_label": "Baha:", "price_label": "Baha:",
"shipping_price": "Eltip berme bahasy",
"extra_price": "Goşmaça baha:", "extra_price": "Goşmaça baha:",
"discount": "Arzanladyş:", "discount": "Arzanladyş:",
"total_price": "Jemi baha:", "total_price": "Jemi baha:",
@@ -33,9 +34,6 @@
"address_search": "Adres gözleg", "address_search": "Adres gözleg",
"address": "Adres", "address": "Adres",
"first_name": "Ady", "first_name": "Ady",
"building": "Jaý",
"floor": "Gat",
"apartment": "Otag",
"save": "Ýatda sakla", "save": "Ýatda sakla",
"enter_phone": "Telefon belgisini giriziň", "enter_phone": "Telefon belgisini giriziň",
"code_will_be_sent": "Biz size kod ugradarys", "code_will_be_sent": "Biz size kod ugradarys",
@@ -48,10 +46,14 @@
"delivery_type": "Elip bermek görnüşi", "delivery_type": "Elip bermek görnüşi",
"delivery": "Eltip bermek", "delivery": "Eltip bermek",
"pickup": "Özüň baryp al", "pickup": "Özüň baryp al",
"standart": "Standart",
"self_pickup": "Özüm baryp aljak",
"region": "Çapar ugrat",
"free": "Mugt",
"payment_type": "Töleg görnüşi", "payment_type": "Töleg görnüşi",
"cash": "Nagt", "cash": "Nagt",
"card": "Kartdan tölemek", "card": "Kartdan tölemek",
"choose_address": "Adres saýla", "choose_address": "Etrap saýla",
"brands": "Brendler", "brands": "Brendler",
"color": "Reňk", "color": "Reňk",
"price": "Baha", "price": "Baha",
@@ -78,13 +80,14 @@
"no": "Ýok", "no": "Ýok",
"yes": "Hawa", "yes": "Hawa",
"cart_empty": "Siziň söwda sebediňiz boş", "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", "go_to_cart": "Sebede geçmek",
"products": "Azyk harytlary", "products": "Harytlar",
"become_seller": "Satyjy bolmak", "become_seller": "Satyjy bolmak",
"choose_region": "Etrap saýlaň", "choose_region": "Welaýat saýlaň",
"choose_or_enter_address": "Salgyňyzy saýlaň ýa-da ýazyň", "choose_or_enter_address": "Salgyňyzy saýlaň ýa-da ýazyň",
"note": "Bellik", "note": "Adresiňiz barada giňişleýin ýazyň",
"seller_application_form": "Dükan açmak üçin arza görnüşi", "seller_application_form": "Dükan açmak üçin arza görnüşi",
"phone": "Telefon", "phone": "Telefon",
"unit_price": "1 san bahasy:", "unit_price": "1 san bahasy:",
@@ -102,6 +105,8 @@
"empty_favorites": "Siziň saýlanan harytlaryňyz ýok", "empty_favorites": "Siziň saýlanan harytlaryňyz ýok",
"removed_from_favorites": "Haryt saýlanlardan aýryldy", "removed_from_favorites": "Haryt saýlanlardan aýryldy",
"added_to_cart": "Haryt sebede goşuldy", "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", "error": "Ýalňyşlyk ýüze çykdy",
"out_of_stock": "Haryt ýok", "out_of_stock": "Haryt ýok",
"personal_info": "Şahsy maglumat", "personal_info": "Şahsy maglumat",
@@ -133,7 +138,7 @@
"product_description": "Haryt barada düşündiriş", "product_description": "Haryt barada düşündiriş",
"adding": "Goşulýar...", "adding": "Goşulýar...",
"added_to_cart_description": "sebede goşuldy", "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", "cart_updated": "Sebet täzelendi",
"update_quantity_failed": "Mukdar täzelenip bolmady", "update_quantity_failed": "Mukdar täzelenip bolmady",
"logging_out": "Çykylýar...", "logging_out": "Çykylýar...",
@@ -163,6 +168,46 @@
"enter_address": "Adres giriziň", "enter_address": "Adres giriziň",
"save_changes": "Ýatda sakla", "save_changes": "Ýatda sakla",
"saving": "Ýatda saklýar...", "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ş",
"register_title": "Hasaba alyş",
"register_description": "Maglumatyňyzy dolduryň",
"register_button": "Hasaba al",
"name_required": "Adyňyzy giriziň",
"address_required": "Salgyňyzy giriziň",
"name_placeholder": "Adyňyz",
"address_placeholder": "Salgyňyz (mysal: Tejen)",
"checking": "Barlanýar...",
"registering": "Hasaba alynýar...",
"registration_success": "Hasaba alyndy! Kody giriziň",
"verify_title": "Kody giriziň",
"verify_description": "Telefonyňyza gelen kody giriziň",
"back": "Yza"
} }

View File

@@ -1,61 +1,29 @@
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios" // lib/api.ts
/** import axios, {
* Token management utilities type AxiosInstance,
*/ type AxiosRequestConfig,
const getTokenFromCookie = (name: string): string | null => { type AxiosResponse,
if (typeof document === "undefined") return null } from "axios";
const value = `; ${document.cookie}` import TokenStorage from "./tokenStorage";
const parts = value.split(`; ${name}=`)
if (parts.length === 2) return parts.pop()?.split(";").shift() || null
return null
}
const setTokenInCookie = (name: string, token: string): void => {
if (typeof document === "undefined") return
document.cookie = `${name}=${token}; path=/; secure; SameSite=Strict; max-age=2592000`
}
const removeTokenFromCookie = (name: string): void => {
if (typeof document === "undefined") return
document.cookie = `${name}=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;`
}
const getToken = (): string | null => {
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 localeToApiLang = (locale: string): string => {
const mapping: Record<string, string> = { const mapping: Record<string, string> = { tm: "tk", ru: "ru" };
tm: "tk", return mapping[locale] || locale;
ru: "ru", };
}
return mapping[locale] || locale
}
/**
* Centralized API client with interceptors
*/
class APIClient { class APIClient {
private client: AxiosInstance private client: AxiosInstance;
private baseUrl: string private baseUrl: string;
private isRefreshing = false private isRefreshing = false;
private failedQueue: Array<{ private failedQueue: Array<{
resolve: (value?: unknown) => void resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void reject: (reason?: unknown) => void;
}> = [] }> = [];
constructor() { 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({ this.client = axios.create({
baseURL: `${this.baseUrl}/api/v1`, baseURL: `${this.baseUrl}/api/v1`,
@@ -64,64 +32,65 @@ class APIClient {
"Content-Type": "application/json", "Content-Type": "application/json",
"Api-Token": process.env.NEXT_PUBLIC_API_TOKEN || "123", "Api-Token": process.env.NEXT_PUBLIC_API_TOKEN || "123",
}, },
}) });
this.setupInterceptors() this.setupInterceptors();
} }
private setupInterceptors(): void { private setupInterceptors(): void {
// Request interceptor // Request interceptor
this.client.interceptors.request.use( this.client.interceptors.request.use(
(config) => { (config) => {
const token = getToken() const token = TokenStorage.getActiveToken();
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`;
} }
// Add language parameter // Add language parameter (except for POST requests to /orders)
let lang = "tk" // default fallback const url = config.url || "";
const isOrderPost =
if (typeof window !== "undefined") { config.method?.toLowerCase() === "post" && url.includes("/orders");
// Try to get from i18n
if ((window as any).i18n?.language) { if (!isOrderPost) {
lang = localeToApiLang((window as any).i18n.language) let lang = "tm";
}
// Try to get from pathname as fallback if (typeof window !== "undefined") {
else { if ((window as any).i18n?.language) {
const pathLocale = window.location.pathname.split("/")[1] lang = localeToApiLang((window as any).i18n.language);
if (pathLocale === "tm" || pathLocale === "ru") { } else {
lang = localeToApiLang(pathLocale) const pathLocale = window.location.pathname.split("/")[1];
if (pathLocale === "tm" || pathLocale === "ru") {
lang = localeToApiLang(pathLocale);
}
} }
} }
const separator = url.includes("?") ? "&" : "?";
config.url = `${url}${separator}lang=${lang}`;
} }
const url = config.url || "" return config;
const separator = url.includes("?") ? "&" : "?"
config.url = `${url}${separator}lang=${lang}`
return config
}, },
(error) => Promise.reject(error) (error) => Promise.reject(error),
) );
// Response interceptor // Response interceptor
this.client.interceptors.response.use( this.client.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { async (error) => {
const originalRequest = error.config const originalRequest = error.config;
// Handle 401 errors
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
if (this.isRefreshing) { if (this.isRefreshing) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.failedQueue.push({ resolve, reject }) this.failedQueue.push({ resolve, reject });
}) })
.then(() => this.client(originalRequest)) .then(() => this.client(originalRequest))
.catch((err) => Promise.reject(err)) .catch((err) => Promise.reject(err));
} }
originalRequest._retry = true originalRequest._retry = true;
this.isRefreshing = true this.isRefreshing = true;
try { try {
const guestTokenResponse = await axios.post( const guestTokenResponse = await axios.post(
@@ -132,31 +101,31 @@ class APIClient {
"Content-Type": "application/json", "Content-Type": "application/json",
"Api-Token": process.env.NEXT_PUBLIC_API_TOKEN || "123", "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) { if (newToken) {
setTokenInCookie("guestToken", newToken) TokenStorage.setGuestToken(newToken);
this.processQueue(null) this.processQueue(null);
return this.client(originalRequest) return this.client(originalRequest);
} }
} catch (refreshError) { } catch (refreshError) {
this.processQueue(refreshError) this.processQueue(refreshError);
this.clearAuthToken() TokenStorage.clearTokens();
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
window.location.href = "/login" window.location.href = "/login";
} }
return Promise.reject(refreshError) return Promise.reject(refreshError);
} finally { } finally {
this.isRefreshing = false this.isRefreshing = false;
} }
} }
// Handle HTML error responses
if ( if (
error.response?.data && error.response?.data &&
typeof error.response.data === "string" && typeof error.response.data === "string" &&
@@ -168,64 +137,62 @@ class APIClient {
...error.response, ...error.response,
data: { message: "Server returned HTML instead of JSON" }, data: { message: "Server returned HTML instead of JSON" },
}, },
}) });
} }
return Promise.reject(error) return Promise.reject(error);
} },
) );
} }
private processQueue(error: any): void { private processQueue(error: any): void {
this.failedQueue.forEach((promise) => { this.failedQueue.forEach((promise) => {
if (error) { if (error) {
promise.reject(error) promise.reject(error);
} else { } else {
promise.resolve() promise.resolve();
} }
}) });
this.failedQueue = [] this.failedQueue = [];
} }
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> { get<T = any>(
return this.client.get<T>(url, config) url: string,
config?: AxiosRequestConfig,
): Promise<AxiosResponse<T>> {
return this.client.get<T>(url, config);
} }
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> { post<T = any>(
return this.client.post<T>(url, data, config) url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<AxiosResponse<T>> {
return this.client.post<T>(url, data, config);
} }
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> { put<T = any>(
return this.client.put<T>(url, data, config) url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<AxiosResponse<T>> {
return this.client.put<T>(url, data, config);
} }
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> { patch<T = any>(
return this.client.patch<T>(url, data, config) url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<AxiosResponse<T>> {
return this.client.patch<T>(url, data, config);
} }
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> { delete<T = any>(
return this.client.delete<T>(url, config) url: string,
} config?: AxiosRequestConfig,
): Promise<AxiosResponse<T>> {
setAuthToken(token: string): void { return this.client.delete<T>(url, config);
removeTokenFromCookie("guestToken")
setTokenInCookie("authToken", token)
this.client.defaults.headers.common["Authorization"] = `Bearer ${token}`
}
setGuestToken(token: string): void {
setTokenInCookie("guestToken", token)
this.client.defaults.headers.common["Authorization"] = `Bearer ${token}`
}
clearAuthToken(): void {
removeTokenFromCookie("authToken")
removeTokenFromCookie("guestToken")
delete this.client.defaults.headers.common["Authorization"]
} }
} }
export const apiClient = new APIClient() 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()

View File

@@ -1,23 +1,25 @@
export * from "../../features/products/hooks/useProducts" export * from "../../features/products/hooks/useProducts";
export * from "../../features/category/hooks/useCategories" export * from "../../features/category/hooks/useCategories";
export * from "../../features/cart/hooks/useCart" export * from "../../features/cart/hooks/useCart";
export * from "../../features/favorites/hooks/useFavorites" export * from "../../features/favorites/hooks/useFavorites";
export * from "../../features/orders/hooks/useOrders" export * from "../../features/orders/hooks/useOrders";
export * from "../../features/search/hooks/useSearch" export * from "../../features/search/hooks/useSearch";
export * from "../../features/profile/hooks/useUserProfile" export * from "../../features/profile/hooks/useUserProfile";
export * from "../../features/openStore/hooks/useOpenStore" export * from "../../features/openStore/hooks/useOpenStore";
export * from "../../features/cart/hooks/useAddresses" export * from "../../features/cart/hooks/useAddresses";
export * from "../../features/cart/hooks/usePaymentTypes" 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 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,22 +1,26 @@
// lib/hooks/useAuth.ts
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react"; 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 ==================== // ==================== TYPES ====================
interface LoginCredentials { interface LoginCredentials {
phone_number: string; phone_number: number;
password?: string; password?: string;
} }
interface RegisterData { interface RegisterData {
phone_number: string; phone_number: string;
name?: string; name: string;
email?: string; address: string;
} }
interface VerifyTokenData { interface VerifyTokenData {
phone_number: string; phone_number: number;
code: string; code: number;
} }
interface AuthResponse { interface AuthResponse {
@@ -30,59 +34,131 @@ interface AuthResponse {
}; };
} }
// ==================== AUTH STATUS ==================== interface AuthError {
const getTokenFromCookie = (name: string): string | null => { message: string;
if (typeof document === "undefined") return null; code?: string;
const value = `; ${document.cookie}`; statusCode?: number;
const parts = value.split(`; ${name}=`); }
if (parts.length === 2) return parts.pop()?.split(";").shift() || null;
return null;
};
// ==================== 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() { export function useAuthStatus() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(false);
useEffect(() => { useEffect(() => {
const authToken = getTokenFromCookie("authToken"); setIsAuthenticated(TokenStorage.hasAuthToken());
setIsAuthenticated(!!authToken);
setIsLoading(false); setIsLoading(false);
}, []); }, []);
return { return { isAuthenticated, isLoading };
isAuthenticated,
isLoading,
};
} }
// ==================== GUEST TOKEN ==================== // ==================== GUEST TOKEN ====================
export function useGetGuestToken() { export function useGetGuestToken() {
return useMutation({ return useMutation({
mutationFn: async (): Promise<AuthResponse> => { mutationFn: async (): Promise<string> => {
const response = await apiClient.post<AuthResponse>("/auth/guest-token", {}); const controller = new AbortController();
return response.data; const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s timeout
},
onSuccess: (data) => { try {
const token = data?.token || data?.data; const response = await apiClient.post<AuthResponse>(
if (token) { "/auth/guest-token",
setGuestToken(token); {},
{
signal: controller.signal,
timeout: 10000,
},
);
clearTimeout(timeoutId);
return extractToken(response.data);
} catch (error) {
clearTimeout(timeoutId);
throw handleAuthError(error);
} }
}, },
onError: (error) => { onSuccess: (token) => {
console.error("Guest token hatası:", error); 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 ==================== // ==================== LOGIN ====================
export function useLogin() { export function useLogin() {
const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (credentials: LoginCredentials): Promise<AuthResponse> => { mutationFn: async (credentials: LoginCredentials): Promise<string> => {
const response = await apiClient.post<AuthResponse>("/auth/login", credentials); const response = await apiClient.post<AuthResponse>(
return response.data; "/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) => { 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(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (userData: RegisterData): Promise<AuthResponse> => { mutationFn: async (userData: RegisterData): Promise<string> => {
const response = await apiClient.post<AuthResponse>("/auth/register", userData); const response = await apiClient.post<AuthResponse>(
return response.data; "/auth/register",
userData,
{ timeout: 15000 },
);
return extractToken(response.data);
}, },
onSuccess: (data) => { onSuccess: (token) => {
const token = data?.token || data?.data; TokenStorage.setAuthToken(token);
if (token) { queryClient.invalidateQueries({ queryKey: ["auth-status"] });
setAuthToken(token); queryClient.invalidateQueries({ queryKey: ["user-profile"] });
queryClient.invalidateQueries({ queryKey: ["auth-status"] });
}
}, },
onError: (error) => { 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(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (verifyData: VerifyTokenData): Promise<AuthResponse> => { mutationFn: async (verifyData: VerifyTokenData): Promise<string> => {
const response = await apiClient.post<AuthResponse>("/auth/verify", verifyData); const response = await apiClient.post<AuthResponse>(
return response.data; "/auth/verify",
verifyData,
{ timeout: 15000 },
);
return extractToken(response.data);
}, },
onSuccess: (data) => { onSuccess: (token) => {
const token = data?.data || data?.token; TokenStorage.setAuthToken(token);
if (token) { queryClient.invalidateQueries({ queryKey: ["auth-status"] });
setAuthToken(token); queryClient.invalidateQueries({ queryKey: ["user-profile"] });
queryClient.invalidateQueries({ queryKey: ["auth-status"] });
}
}, },
onError: (error) => { onError: (error) => {
console.error("Verify hatası:", error); const authError = handleAuthError(error);
console.error("[Verify] Failed:", authError);
}, },
}); });
} }
@@ -138,23 +220,20 @@ export function useLogout() {
return useMutation({ return useMutation({
mutationFn: async (): Promise<void> => { mutationFn: async (): Promise<void> => {
try { try {
await apiClient.post("/auth/logout"); await apiClient.post("/auth/logout", {}, { timeout: 5000 });
} catch (error) { } catch (error) {
console.warn("Logout endpoint çalışmadı:", error); console.warn(
"[Logout] Server call failed, clearing local state anyway",
);
} }
}, },
onSuccess: () => { onSuccess: () => {
clearAuthToken(); TokenStorage.clearTokens();
queryClient.clear(); queryClient.clear();
if (typeof window !== "undefined") {
window.location.href = "/login";
}
}, },
onError: (error) => { onError: () => {
console.error("Logout hatası:", error); TokenStorage.clearTokens();
clearAuthToken();
queryClient.clear(); 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 type DeliveryType = "SELECTED_DELIVERY" | "PICK_UP";
export interface PaymentType { export interface PaymentType {
@@ -137,7 +153,8 @@ export interface CartItem {
} }
export interface CartResponse { export interface CartResponse {
message?: string; message: string;
errorDetails?: string;
data: CartItem[]; data: CartItem[];
count?: number; count?: number;
total?: number; total?: number;
@@ -184,6 +201,7 @@ export interface Order {
id: number; id: number;
status: string; status: string;
shipping_method: string; shipping_method: string;
shipping_price?: number;
notes: string | null; notes: string | null;
customer_name: string; customer_name: string;
customer_phone: string; customer_phone: string;
@@ -219,7 +237,7 @@ export interface CreateOrderRequest {
delivery_time?: string; delivery_time?: string;
delivery_at?: string; delivery_at?: string;
region: string; region: string;
note?: string; notes?: string;
} }
export interface CreateOrderPayload { export interface CreateOrderPayload {
@@ -227,11 +245,12 @@ export interface CreateOrderPayload {
customer_phone?: string; customer_phone?: string;
customer_address: string; customer_address: string;
shipping_method: string; shipping_method: string;
shipping_price: number;
payment_type_id: number; payment_type_id: number;
delivery_time?: string; delivery_time?: string;
delivery_at?: string; delivery_at?: string;
region: string; region: string;
note?: string; notes?: string;
} }
// Pagination Types // Pagination Types
@@ -380,6 +399,11 @@ export interface ShippingMethod {
code: string; code: string;
} }
export interface OrderDelivery {
name: string;
price: number;
}
// Generic API Error Response // Generic API Error Response
export interface ApiError { export interface ApiError {
message: string; message: string;

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 = { const nextConfig: NextConfig = {
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: false,
}, },
images: { images: {
unoptimized: true, unoptimized: true,
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
hostname: "shop.post.tm", hostname: "hyzmat.app",
// port: "8080",
}, },
], ],
}, },

2517
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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