first commit

This commit is contained in:
Jelaletdin12
2026-02-01 20:55:57 +05:00
commit b8c871750a
128 changed files with 23114 additions and 0 deletions

41
.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
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
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
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.

11
Soraglar.txt Normal file
View File

@@ -0,0 +1,11 @@
1. Home page category suratlar acanok
2. Harytlar kem kas bolanu ucin home page doly gorkezenok
4. Order nadip otmen etmeli.
5. Review feed back yazylyan yer bamy bolmalymy
7. Delivery type soramaly, type lar yok

261
app/[locale]/cart/page.tsx Normal file
View File

@@ -0,0 +1,261 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import CartItemCard from "../../../features/cart/components/CartItemCard";
import CartItemSkeleton from "../../../features/cart/components/CartItemSkeleton";
import OrderSummary from "../../../features/cart/components/OrderSummary";
import OrderSummarySkeleton from "../../../features/cart/components/OrderSummarySkeleton";
import {
useCart,
useCreateOrder,
useRegions,
usePaymentTypes,
} from "@/lib/hooks";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import type { DeliveryType, PaymentType } from "@/lib/types/api";
import EmptyCart from "@/features/cart/components/EmptyCart";
import ErrorPage from "@/components/ErrorPage";
export default function CartPage() {
const [isClient, setIsClient] = useState(false);
const [paymentType, setPaymentType] = useState<PaymentType | null>(null);
const [deliveryType, setDeliveryType] =
useState<DeliveryType>("SELECTED_DELIVERY");
const [selectedRegion, setSelectedRegion] = useState<string>("");
const [selectedProvince, setSelectedProvince] = useState<number | null>(null);
const [note, setNote] = useState<string>("");
const [phone, setPhone] = useState<string>("+993 ");
const [name, setName] = useState<string>("");
const [lastName, setLastName] = useState<string>("");
const router = useRouter();
const t = useTranslations();
const { data: cartResponse, isLoading: cartLoading, isError } = useCart();
const { data: provinces = [], isLoading: provincesLoading } = useRegions();
const { data: paymentTypes = [], isLoading: paymentTypesLoading } =
usePaymentTypes();
const { mutate: createOrder, isPending: isCreatingOrder } = useCreateOrder();
const cartItems = cartResponse?.data || [];
const isLoading = cartLoading || provincesLoading || paymentTypesLoading;
useEffect(() => {
setIsClient(true);
}, []);
const regionGroups = useMemo(() => {
return provinces.reduce((acc, province) => {
if (!acc[province.region]) {
acc[province.region] = [];
}
acc[province.region].push(province);
return acc;
}, {} as Record<string, typeof provinces>);
}, [provinces]);
const availableRegions = useMemo(
() => Object.keys(regionGroups),
[regionGroups]
);
const itemsBySeller = useMemo(() => {
return cartItems.reduce((acc, item) => {
const sellerId = item.product.channel?.[0]?.id || 0;
const sellerName = item.product.channel?.[0]?.name || "Unknown Seller";
if (!acc[sellerId]) {
acc[sellerId] = {
seller: { id: sellerId, name: sellerName },
items: [],
};
}
acc[sellerId].items.push(item);
return acc;
}, {} as Record<number, { seller: { id: number; name: string }; items: typeof cartItems }>);
}, [cartItems]);
const totalAmount = useMemo(() => {
return cartItems.reduce((sum, item) => {
const price = parseFloat(item.product.price_amount || "0");
return sum + price * item.product_quantity;
}, 0);
}, [cartItems]);
const handleDeliveryTypeChange = (type: DeliveryType) => {
setDeliveryType(type);
setSelectedProvince(null);
};
const formatPhoneForBackend = (phoneNumber: string): string => {
return phoneNumber.replace(/^\+993\s*/, "").replace(/\s+/g, "");
};
const handleCompleteOrder = () => {
if (!selectedRegion || !selectedProvince || !paymentType || !phone || !name) {
console.warn("Missing required fields for order");
return;
}
const phoneDigits = formatPhoneForBackend(phone);
if (phoneDigits.length !== 8) {
console.warn("Phone number must be exactly 8 digits");
return;
}
const selectedProvinceData = provinces.find((p) => p.id === selectedProvince);
if (!selectedProvinceData) return;
createOrder(
{
customer_name: `${name} ${lastName}`.trim(),
customer_phone: parseInt(phoneDigits, 10),
customer_address: selectedProvinceData.name,
shipping_method: "standart",
payment_type_id: paymentType.id,
region: selectedRegion,
note: note || undefined,
},
{
onSuccess: () => {
router.push(`/orders`);
},
}
);
};
if (!isClient) return null;
if (isLoading) {
return (
<div className="mx-auto px-2 md:px-4 lg:px-6 mb-18">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold mb-4 md:mb-6 pt-3">
{t("cart")}
</h1>
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1">
<Card className="p-4 md:p-6 rounded-xl">
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<CartItemSkeleton key={i} />
))}
</div>
</Card>
</div>
<OrderSummarySkeleton />
</div>
</div>
);
}
if (isError ) {
return <ErrorPage />;
}
if (cartItems.length === 0) {
return <EmptyCart />;
}
return (
<div className="flex flex-col mx-auto max-w-[1504px] px-2 md:px-4 lg:px-6 mb-18">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold mb-4 md:mb-6 pt-3">
{t("cart")}
</h1>
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1">
<Card className="p-4 md:p-6 rounded-xl">
{Object.entries(itemsBySeller).map(
([sellerId, { seller, items }]) => (
<div key={sellerId} className="mb-6">
<p className="text-base font-semibold mb-3">{seller.name}</p>
<div className="space-y-4">
{items.map((item) => {
const price = parseFloat(
item.product.price_amount || "0"
);
const quantity = item.product_quantity;
const total = price * quantity;
return (
<CartItemCard
key={item.id}
item={{
...item,
quantity: quantity,
price: price,
total: total,
seller: seller,
price_formatted: `${item.product.price_amount} TMT`,
sub_total_formatted: `${item.product.price_amount} TMT`,
total_formatted: `${total.toFixed(2)} TMT`,
discount_formatted: "0 TMT",
product: {
...item.product,
image:
item.product.media?.[0]?.images_800x800 ||
item.product.media?.[0]?.thumbnail,
images:
item.product.media?.map(
(m) => m.images_800x800 || m.thumbnail
) || [],
},
}}
/>
);
})}
</div>
{Object.entries(itemsBySeller).length > 1 && (
<Separator className="mt-4" />
)}
</div>
)
)}
</Card>
</div>
<OrderSummary
order={{
id: 1,
billing: {
body: [
{
title: t("products"),
value: `${totalAmount.toFixed(2)} TMT`,
},
],
footer: {
title: t("total_price"),
value: `${totalAmount.toFixed(2)} TMT`,
},
},
}}
paymentType={paymentType}
deliveryType={deliveryType}
selectedRegion={selectedRegion}
selectedProvince={selectedProvince}
note={note}
regionGroups={regionGroups}
availableRegions={availableRegions}
paymentTypes={paymentTypes}
phone={phone}
name={name}
lastName={lastName}
onPhoneChange={setPhone}
onNameChange={setName}
onLastNameChange={setLastName}
onPaymentTypeChange={setPaymentType}
onDeliveryTypeChange={handleDeliveryTypeChange}
onRegionChange={setSelectedRegion}
onProvinceChange={setSelectedProvince}
onNoteChange={setNote}
onCompleteOrder={handleCompleteOrder}
isLoading={isCreatingOrder}
/>
</div>
</div>
);
}

View File

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

View File

@@ -0,0 +1,64 @@
import type { Metadata } from "next";
type Props = {
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 async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params;
const meta = META[locale as keyof typeof META] ?? META.ru;
const collectionName = formatSlug(slug);
const title = `${collectionName}${meta.titleSuffix}`;
const description = meta.description(collectionName);
return {
title,
description,
openGraph: {
type: "website",
locale: meta.ogLocale,
title,
description,
},
};
}
export async function generateStaticParams() {
const collections = ["new-arrivals", "best-sellers", "featured"];
return collections.map((slug) => ({ slug }));
}
export default async function CollectionPage(props: Props) {
const params = await props.params;
const CollectionPageClient = (
await import(
"../../../../features/collections/components/CollectionPageClient"
)
).default;
return <CollectionPageClient params={params} />;
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useFavorites } from "@/lib/hooks";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "next-intl";
import ProductCard from "@/features/home/components/ProductCard";
import type { Favorite } from "@/lib/types/api";
import EmptyFavorites from "@/features/favorites/components/EmptyFavorites";
import ErrorPage from "@/components/ErrorPage";
import Placeholder from "@/public/logo.webp";
export default function FavoritesPage() {
const t = useTranslations();
const { data: favorites, isLoading, isError } = useFavorites();
if (isLoading) {
return (
<div className="mx-auto px-2 md:px-4 lg:px-6 pb-12 space-y-8 max-w-[1504px]">
<h1 className="bg-white text-3xl p-4 font-bold mb-0 pb-6">
{t("favorite_products")}
</h1>
<div className="bg-white grid grid-cols-2 sm:grid-cols-3 rounded-b-lg md:grid-cols-4 lg:grid-cols-5 gap-3 p-4">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="w-full h-[260px] rounded-xl" />
<Skeleton className="h-4 w-3/4 mx-2" />
<Skeleton className="h-6 w-1/2 mx-2" />
</div>
))}
</div>
</div>
);
}
if (isError) {
return <ErrorPage />;
}
if (!favorites || favorites.length === 0) {
return <EmptyFavorites />;
}
return (
<div
className=" mx-auto px-2 md:px-4 lg:px-6 pb-12 space-y-8 max-w-[1504px]
"
>
<h1 className="bg-white text-3xl p-4 font-bold mb-0 pb-6">
{t("favorite_products")}
</h1>
<div className="bg-white grid grid-cols-2 sm:grid-cols-3 rounded-b-lg md:grid-cols-4 lg:grid-cols-5 gap-3 p-4">
{favorites.map((favorite: Favorite) => {
const product = favorite.product;
const allImages = product.media
?.map(
(media) =>
media.images_800x800 ||
media.images_720x720 ||
media.images_400x400 ||
media.thumbnail
)
.filter(Boolean) || [Placeholder];
const formattedPrice = product.price_amount
? `${parseFloat(product.price_amount).toFixed(2)} TMT`
: "Price not available";
return (
<ProductCard
key={product.id}
id={product.id}
name={product.name}
price={
product.price_amount ? parseFloat(product.price_amount) : null
}
struct_price_text={formattedPrice}
images={allImages}
labels={[]}
price_color="#0059ff"
height={360}
width={250}
button={false}
stock={product.stock}
/>
);
})}
</div>
</div>
);
}

236
app/[locale]/globals.css Normal file
View File

@@ -0,0 +1,236 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: #eff3f6;
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.21 0.006 285.885);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
[data-sonner-toast] [data-description] {
color: #000 !important;
opacity: 0.9;
}
@layer utilities {
.text-fg {
color: var(--fg);
}
.bg-bg {
background-color: var(--bg);
}
.stroke-primary {
stroke: #005bff;
}
.stroke-track {
stroke: hsla(var(--hue), 10%, 10%, 0.1);
transition: stroke var(--trans-dur);
}
@media (prefers-color-scheme: dark) {
.stroke-track {
stroke: hsla(var(--hue), 10%, 90%, 0.1);
}
}
.animate-msg {
animation: msg 0.3s 13.7s linear forwards;
}
.animate-msgLast {
animation: msg 0.3s 14s linear reverse forwards;
}
.animate-cartLines {
animation: cartLines 2s ease-in-out infinite;
}
.animate-cartTop {
animation: cartTop 2s ease-in-out infinite;
}
.animate-cartWheel1 {
animation: cartWheel1 2s ease-in-out infinite;
transform: rotate(-0.25turn);
transform-origin: 43px 111px;
}
.animate-cartWheel2 {
animation: cartWheel2 2s ease-in-out infinite;
transform: rotate(0.25turn);
transform-origin: 102px 111px;
}
.animate-cartWheelStroke {
animation: cartWheelStroke 2s ease-in-out infinite;
}
}
@keyframes msg {
from {
opacity: 1;
visibility: visible;
}
99.9% {
opacity: 0;
visibility: visible;
}
to {
opacity: 0;
visibility: hidden;
}
}
@keyframes cartLines {
from,
to {
opacity: 0;
}
8%,
92% {
opacity: 1;
}
}
@keyframes cartTop {
from {
stroke-dashoffset: -338;
}
50% {
stroke-dashoffset: 0;
}
to {
stroke-dashoffset: 338;
}
}
@keyframes cartWheel1 {
from {
transform: rotate(-0.25turn);
}
to {
transform: rotate(2.75turn);
}
}
@keyframes cartWheel2 {
from {
transform: rotate(0.25turn);
}
to {
transform: rotate(3.25turn);
}
}
@keyframes cartWheelStroke {
from,
to {
stroke-dashoffset: 81.68;
}
50% {
stroke-dashoffset: 40.84;
}
}

67
app/[locale]/layout.tsx Normal file
View File

@@ -0,0 +1,67 @@
import type React from "react"
import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import { notFound } from "next/navigation"
import { NextIntlClientProvider } from "next-intl"
import "./globals.css"
import Header from "@/components/layout/Header"
import MobileBottomNav from "@/components/layout/MobileBar"
import { Toaster } from "@/components/ui/sonner"
import { Providers } from "@/context/Provider"
import AuthWrapper from "@/context/AuthWrapper"
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
})
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
})
export const metadata: Metadata = {
title: "Postshop",
description: "E-commerce platform",
}
type Props = {
children: React.ReactNode
params: Promise<{ locale: string }>
}
const locales = ["ru", "tm"]
export function generateStaticParams() {
return locales.map((locale) => ({ locale }))
}
export default async function RootLayout({ children, params }: Props) {
const { locale } = await params
if (!locales.includes(locale)) notFound()
let messages
try {
messages = (await import(`../../i18n/messages/${locale}.json`)).default
} catch {
messages = {}
}
return (
<html lang={locale} suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<Providers>
<NextIntlClientProvider locale={locale} messages={messages}>
<AuthWrapper locale={locale}>
<Header locale={locale} />
{children}
<MobileBottomNav locale={locale} />
<Toaster />
</AuthWrapper>
</NextIntlClientProvider>
</Providers>
</body>
</html>
)
}

16
app/[locale]/me/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import type { Metadata } from "next"
import ClientProfilePage from "../../../features/profile/components/ProfilePageContent"
export const metadata: Metadata = {
title: "My Profile | E-Commerce",
description: "Manage your profile settings",
robots: "noindex, nofollow", // Private page
}
export default function ProfilePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
return <ClientProfilePage params={params} />
}

View File

@@ -0,0 +1,280 @@
"use client";
import type React from "react";
import { useState } from "react";
import { Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useOpenStore } from "@/lib/hooks";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
interface OpenStorePageProps {
locale?: string;
translations?: {
title: string;
firstName: string;
lastName: string;
email: string;
phone: string;
uploadPatent: string;
submit: string;
selectedFile: string;
firstNameRequired: string;
lastNameRequired: string;
emailInvalid: string;
phoneInvalid: string;
fileRequired: string;
fileSizeError: string;
fileTypeError: string;
};
}
interface FormData {
firstName: string;
lastName: string;
email: string;
phone: string;
file: File | null;
}
interface FormErrors {
firstName?: string;
lastName?: string;
email?: string;
phone?: string;
file?: string;
}
export default function OpenStorePage({}: OpenStorePageProps) {
const [formData, setFormData] = useState<FormData>({
firstName: "",
lastName: "",
email: "",
phone: "+993",
file: null,
});
const [errors, setErrors] = useState<FormErrors>({});
const [fileName, setFileName] = useState("");
const { mutate: submitOpenStore, isPending: loading } = useOpenStore();
const t = useTranslations();
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
if (!formData.firstName.trim()) {
newErrors.firstName = t("requiredField");
}
if (!formData.lastName.trim()) {
newErrors.lastName = t("requiredField");
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
newErrors.email = t("requiredField");
}
const phoneRegex = /^\+?[0-9]{6,15}$/;
if (!phoneRegex.test(formData.phone)) {
newErrors.phone = t("requiredField");
}
if (!formData.file) {
newErrors.file = t("fileRequired");
} else {
const allowedTypes = ["image/jpeg", "image/jpg", "application/pdf"];
if (!allowedTypes.includes(formData.file.type)) {
newErrors.file = t("fileTypeError");
}
if (formData.file.size > 25 * 1024 * 1024) {
newErrors.file = t("fileSizeError");
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
if (errors[name as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [name]: undefined }));
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setFormData((prev) => ({ ...prev, file }));
setFileName(file.name);
if (errors.file) {
setErrors((prev) => ({ ...prev, file: undefined }));
}
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
if (formData.file) {
submitOpenStore(
{
firstName: formData.firstName,
lastName: formData.lastName,
email: formData.email,
phone: formData.phone,
patentFile: formData.file,
},
{
onSuccess: () => {
toast.success(t("submit_success"));
setFormData({
firstName: "",
lastName: "",
email: "",
phone: "+993",
file: null,
});
setFileName("");
},
onError: (error: any) => {
toast.error(error?.message || t("submit_error"));
},
}
);
}
};
return (
<div className=" bg-gray-50 flex items-center justify-center p-4">
<Card className="w-full max-w-md shadow-lg">
<CardHeader>
<CardTitle className="text-2xl text-center">{t("title")}</CardTitle>
<CardDescription className="text-center">
Заполните форму для подачи заявления
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* First Name */}
<div className="space-y-2">
<Label htmlFor="firstName">{t("enter_first_name")}</Label>
<Input
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleInputChange}
className={errors.firstName ? "border-red-500" : ""}
/>
{errors.firstName && (
<p className="text-sm text-red-500">{errors.firstName}</p>
)}
</div>
{/* Last Name */}
<div className="space-y-2">
<Label htmlFor="lastName">{t("enter_last_name")}</Label>
<Input
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleInputChange}
className={errors.lastName ? "border-red-500" : ""}
/>
{errors.lastName && (
<p className="text-sm text-red-500">{errors.lastName}</p>
)}
</div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="email">{t("enter_email")}</Label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleInputChange}
className={errors.email ? "border-red-500" : ""}
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
{/* Phone */}
<div className="space-y-2">
<Label htmlFor="phone">{t("enter_phone")}</Label>
<Input
id="phone"
name="phone"
value={formData.phone}
onChange={handleInputChange}
placeholder="+99361111111"
className={errors.phone ? "border-red-500" : ""}
/>
{errors.phone && (
<p className="text-sm text-red-500">{errors.phone}</p>
)}
</div>
{/* File Upload */}
<div className="space-y-2">
<Label htmlFor="file">{t("uploadPatent")}</Label>
<div className="flex flex-col gap-2">
<Input
id="file"
type="file"
accept=".pdf,.jpg,.jpeg"
onChange={handleFileChange}
className="hidden"
/>
<Button
type="button"
variant="outline"
className="w-full bg-transparent cursor-pointer"
onClick={() => document.getElementById("file")?.click()}
>
<Upload className="mr-2 h-4 w-4" />
{t("uploadPatent")}
</Button>
{fileName && (
<p className="text-sm text-gray-600">
{t("selectedFile")}: {fileName}
</p>
)}
{errors.file && (
<p className="text-sm text-red-500">{errors.file}</p>
)}
</div>
</div>
{/* Submit Button */}
<Button
type="submit"
className="w-full cursor-pointer bg-[#005bff] hover:bg-[#0041c4]"
disabled={loading}
>
{loading ? t("submitting") : t("submit")}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import type { Metadata, ResolvingMetadata } from "next";
import OrdersPageClient from "../../../features/orders/components/OrderPage";
const metadataContent = {
tm: {
title: "Meniň Sargytlarym | Post shop",
description: "Sargytlaryňyzy görüň",
},
ru: {
title: "Мои Заказы | Пост-магазин",
description: "Просмотр истории заказов",
},
} as const;
interface PageProps {
params: Promise<{
locale: string;
}>;
}
export async function generateMetadata(
{ params }: PageProps,
parent: ResolvingMetadata
): Promise<Metadata> {
const { locale } = await params;
const localeKey = locale as keyof typeof metadataContent;
const content = metadataContent[localeKey] || metadataContent.ru;
return {
title: content.title,
description: content.description,
robots: {
index: false,
follow: false,
nocache: true,
},
};
}
export default async function OrdersPage({ params }: PageProps) {
const { locale } = await params;
return <OrdersPageClient locale={locale} />;
}

33
app/[locale]/page.tsx Normal file
View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import HomePage from "@/features/home/components/HomePage";
const META = {
ru: {
title: "Интернет магазин - Лучшие товары по низким ценам",
description: "Качественные товары с быстрой доставкой по всей стране",
},
tm: {
title: "Post shop - Iň gowy harytlar, amatly bahada",
description:
"Ýokary hilli harytlar. Elektronika, eşik, arassaçylyk, sport, kosmetika",
},
} as const;
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const { title, description } = META[locale as keyof typeof META] || META.ru;
return {
title,
description,
openGraph: { type: "website", locale, title, description },
};
}
export default function Page() {
return <HomePage />;
}

View File

@@ -0,0 +1,37 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import ProductPageContent from "../../../../features/products/components/ProductPageContent";
type Props = {
params: { locale: string; slug: string };
};
export const revalidate = 3600; // ISR: Revalidate every hour
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale, slug } = await params;
return {
title: `Product ${slug} | E-Commerce`,
description: `View details for product ${slug}`,
openGraph: {
locale,
type: "website",
title: `Product ${slug} | E-Commerce`,
description: `View details for product ${slug}`,
},
};
}
export async function generateStaticParams() {
return [{ slug: "nike-air-max" }, { slug: "adidas-ultraboost" }];
}
export default async function ProductPage(props: Props) {
const params = await props.params;
if (!params.slug) {
notFound();
}
return <ProductPageContent slug={params.slug} />;
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

22
components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

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

@@ -0,0 +1,79 @@
import React from "react";
import { useTranslations } from "next-intl";
const Preloader: React.FC = () => {
const t =useTranslations();
return (
<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">
{/* SVG Konteyner */}
<svg
className="block mx-auto mb-6 w-32 h-32"
role="img"
aria-label="Shopping cart line animation"
viewBox="0 0 128 128"
xmlns="http://www.w3.org/2000/svg"
>
<g
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="8"
>
<g className="stroke-track">
<polyline points="4,4 21,4 26,22 124,22 112,64 35,64 39,80 106,80" />
<circle cx="43" cy="111" r="13" />
<circle cx="102" cy="111" r="13" />
</g>
<g className="stroke-primary animate-cartLines">
<polyline
className="animate-cartTop"
points="4,4 21,4 26,22 124,22 112,64 35,64 39,80 106,80"
strokeDasharray="338 338"
strokeDashoffset="-338"
/>
<g className="animate-cartWheel1">
<circle
className="animate-cartWheelStroke"
cx="43"
cy="111"
r="13"
strokeDasharray="81.68 81.68"
strokeDashoffset="81.68"
/>
</g>
<g className="animate-cartWheel2">
<circle
className="animate-cartWheelStroke"
cx="102"
cy="111"
r="13"
strokeDasharray="81.68 81.68"
strokeDashoffset="81.68"
/>
</g>
</g>
</g>
</svg>
<div className="relative h-6">
<p className="absolute w-full animate-msg text-lg">
{t('loading')}
</p>
{/* <p className="absolute w-full opacity-0 invisible animate-msgLast text-lg">
This is taking long. Somethings wrong.
</p> */}
</div>
</div>
</div>
);
};
export default Preloader;

65
components/icons.tsx Normal file
View File

@@ -0,0 +1,65 @@
export const FavoriteIcon = () => (
<svg
fill="gray"
aria-hidden="true"
data-testid="FavoriteBorderIcon"
viewBox="0 0 24 24"
>
<path d="M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3m-4.4 15.55-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05" />
</svg>
);
export const OrderIcon = () => (
<svg
fill="gray"
aria-hidden="true"
data-testid="LocalShippingIcon"
viewBox="0 0 24 24"
>
<path d="M20 8h-3V4H3c-1.1 0-2 .9-2 2v11h2c0 1.66 1.34 3 3 3s3-1.34 3-3h6c0 1.66 1.34 3 3 3s3-1.34 3-3h2v-5zM6 18.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5m13.5-9 1.96 2.5H17V9.5zm-1.5 9c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5" />
</svg>
);
export const CartIcon = () => (
<svg
fill="gray"
aria-hidden="true"
data-testid="ShoppingBasketIcon"
viewBox="0 0 24 24"
>
<path d="m17.21 9-4.38-6.56c-.19-.28-.51-.42-.83-.42s-.64.14-.83.43L6.79 9H2c-.55 0-1 .45-1 1 0 .09.01.18.04.27l2.54 9.27c.23.84 1 1.46 1.92 1.46h13c.92 0 1.69-.62 1.93-1.46l2.54-9.27L23 10c0-.55-.45-1-1-1zM9 9l3-4.4L15 9zm3 8c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2" />
</svg>
);
export const CategoryIcon = () => (
<svg
fill="white"
aria-hidden="true"
data-testid="WidgetsIcon"
viewBox="0 0 24 24"
>
<path d="M13 13v8h8v-8zM3 21h8v-8H3zM3 3v8h8V3zm13.66-1.31L11 7.34 16.66 13l5.66-5.66z" />
</svg>
);
export const SearchIcon = () => (
<svg
fill="white"
aria-hidden="true"
data-testid="SearchIcon"
viewBox="0 0 20 20"
>
<path d="M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14" />
</svg>
);
export const ProfileIcon = () => (
<svg
fill="gray"
aria-hidden="true"
data-testid="FaceIcon"
viewBox="0 0 24 24"
>
<path d="M9 11.75a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5m6 0a1.25 1.25 0 1 0 0 2.5 1.25 1.25 0 0 0 0-2.5M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8 0-.29.02-.58.05-.86 2.36-1.05 4.23-2.98 5.21-5.37a9.97 9.97 0 0 0 10.41 3.97c.21.71.33 1.47.33 2.26 0 4.41-3.59 8-8 8" />
</svg>
);

View File

@@ -0,0 +1,132 @@
// Header.tsx
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import Image from "next/image";
import { X, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import Logo from "@/public/logo.webp";
import CategoryMenu from "./ui/CategoryMenu";
import SearchBar from "./ui/SearchBar";
import AuthDialog from "./ui/AuthDialog";
import ActionButtons from "./ui/ActionButtons";
import LanguageSelector from "./ui/LanguageSelector";
import MobileBottomNav from "./MobileBar";
import { useAuthStatus } from "@/lib/hooks/useAuth";
import { useTranslations } from "next-intl";
import { CategoryIcon } from "../icons";
interface HeaderProps {
locale?: string;
}
export default function Header({ locale = "ru" }: HeaderProps) {
const [isClient, setIsClient] = useState(false);
const [isCategoryOpen, setIsCategoryOpen] = useState(false);
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false);
const [isLoginOpen, setIsLoginOpen] = useState(false);
const t = useTranslations();
const { isAuthenticated } = useAuthStatus();
useEffect(() => {
setIsClient(true);
}, []);
const handleAuthClick = useCallback(() => {
if (isAuthenticated) {
window.location.href = `/${locale}/me`;
} else {
setIsLoginOpen(true);
}
}, [isAuthenticated, locale]);
const toggleCategoryMenu = useCallback(() => {
setIsCategoryOpen((prev) => !prev);
}, []);
const closeCategoryMenu = useCallback(() => {
setIsCategoryOpen(false);
}, []);
if (!isClient) return null;
return (
<>
<header className="sticky top-0 z-50 w-full border-b bg-white shadow-sm">
<div className="mx-auto px-4">
<div className="flex h-16 items-center justify-between gap-3">
<Link href="/" className="shrink-0">
<div className="relative h-8 w-[180px]">
<Image
src={Logo}
alt="Logo"
fill
className="object-contain"
priority
/>
</div>
</Link>
<Button
data-catalog-trigger
onClick={toggleCategoryMenu}
className="cursor-pointer hidden gap-2 rounded-lg font-bold lg:flex hover:bg-[#005bff] bg-[#005bff] text-white"
size="lg"
>
{isCategoryOpen ? <X className="h-5 w-5" /> : <CategoryIcon />}
{t("common.catalog")}
</Button>
<div className="flex items-center gap-2 sm:hidden cursor-pointer">
<Button
variant="ghost"
size="icon"
onClick={() => setIsMobileSearchOpen(true)}
>
<Search className="h-5 w-5" />
</Button>
<LanguageSelector />
</div>
<div className="hidden sm:block">
<LanguageSelector />
</div>
<SearchBar
isMobile={false}
searchPlaceholder={t("common.search")}
className="hidden flex-1 md:flex"
locale={locale}
/>
<ActionButtons
isAuthenticated={isAuthenticated}
onAuthClick={handleAuthClick}
/>
</div>
</div>
</header>
<CategoryMenu isOpen={isCategoryOpen} onClose={closeCategoryMenu} />
<SearchBar
isMobile={true}
isOpen={isMobileSearchOpen}
onClose={() => setIsMobileSearchOpen(false)}
searchPlaceholder={t("common.search")}
locale={locale}
/>
<AuthDialog isOpen={isLoginOpen} onClose={() => setIsLoginOpen(false)} />
<MobileBottomNav
locale={locale}
onLoginClick={() => {
setIsLoginOpen(true);
}}
/>
</>
);
}

View File

@@ -0,0 +1,232 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { Menu, Heart, Truck, ShoppingCart, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useCategories, useFavorites, useOrders } from "@/lib/hooks";
import { useCartCount } from "@/features/cart/hooks/useCart";
import { useRouter } from "next/navigation";
import { useAuthStatus } from "@/lib/hooks/useAuth";
import { useTranslations } from "next-intl";
import AuthDialog from "./ui/AuthDialog";
interface MobileBottomNavProps {
locale?: string;
translations?: {
catalog: string;
favorites: string;
orders: string;
cart: string;
login: string;
profile: string;
};
onLoginClick?: () => void;
}
export default function MobileBottomNav({
locale = "ru",
translations,
onLoginClick,
}: MobileBottomNavProps) {
const [isClient, setIsClient] = useState(false);
const [isCategoryOpen, setIsCategoryOpen] = useState(false);
const [isLoginOpen, setIsLoginOpen] = useState(false);
const t = useTranslations();
const { isAuthenticated, isLoading: authLoading } = useAuthStatus();
const { data: categories = [] } = useCategories();
const cartCount = useCartCount();
const { data: favoritesData } = useFavorites();
const { data: ordersData } = useOrders();
const router = useRouter();
useEffect(() => {
setIsClient(true);
}, []);
const handleProfileClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (authLoading) {
return;
}
if (isAuthenticated) {
router.push(`/${locale}/me`);
} else {
if (onLoginClick) {
onLoginClick();
} else {
setIsLoginOpen(true);
}
}
};
const handleNavigation = (path: string) => (e: React.MouseEvent) => {
e.preventDefault();
router.push(path);
};
if (!isClient) return null;
return (
<>
{/* Mobile Bottom Navigation */}
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t shadow-lg lg:hidden">
<div className="flex items-center justify-around h-16 px-2">
{/* Catalog Button */}
<Button
variant="ghost"
size="sm"
className="flex-col gap-0.5 h-auto px-2 py-2"
onClick={() => {
setIsCategoryOpen(true);
}}
>
<Menu className="h-5 w-5 text-gray-600" />
<span className="text-xs text-gray-700">{t("common.catalog")}</span>
</Button>
{/* Favorites Button */}
<Button
variant="ghost"
size="sm"
className="relative flex-col gap-0.5 h-auto px-2 py-2"
onClick={handleNavigation("/favorites")}
>
<div className="relative">
<Heart className="h-5 w-5 text-gray-600" />
{(favoritesData?.length || 0) > 0 && (
<Badge
variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
>
{favoritesData?.length}
</Badge>
)}
</div>
<span className="text-xs text-gray-700">
{t("common.favorites")}
</span>
</Button>
{/* Orders Button */}
<Button
variant="ghost"
size="sm"
className="relative flex-col gap-0.5 h-auto px-2 py-2"
onClick={handleNavigation("/orders")}
>
<div className="relative">
<Truck className="h-5 w-5 text-gray-600" />
{(ordersData?.length || 0) > 0 && (
<Badge
variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
>
{ordersData?.length}
</Badge>
)}
</div>
<span className="text-xs text-gray-700">{t("common.orders")}</span>
</Button>
{/* Cart Button - OPTIMIZED */}
<Button
variant="ghost"
size="sm"
className="relative flex-col gap-0.5 h-auto px-2 py-2"
onClick={handleNavigation("/cart")}
>
<div className="relative">
<ShoppingCart className="h-5 w-5 text-gray-600" />
{cartCount > 0 && (
<Badge
variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
>
{cartCount}
</Badge>
)}
</div>
<span className="text-xs text-gray-700">{t("common.cart")}</span>
</Button>
{/* Profile/Login Button */}
<Button
variant="ghost"
size="sm"
className="flex-col gap-0.5 h-auto px-2 py-2"
onClick={handleProfileClick}
disabled={authLoading}
>
<User className="h-5 w-5 text-gray-600" />
<span className="text-xs text-gray-700">
{authLoading
? "..."
: isAuthenticated
? t("common.profile")
: t("common.login")}
</span>
</Button>
</div>
</div>
{/* Category Sheet/Drawer */}
<Sheet open={isCategoryOpen} onOpenChange={setIsCategoryOpen}>
<SheetContent side="left" className="w-[300px] p-0">
<SheetHeader className="p-4 border-b">
<SheetTitle>{t("common.catalog")}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-80px)]">
<div className="p-4">
{categories.map((category) => (
<div key={category.id} className="mb-4">
<Link
href={`/category/${category.slug}?category_id=${category.id}`}
onClick={() => setIsCategoryOpen(false)}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors font-semibold"
>
<span>{category.name}</span>
</Link>
{/* Subcategories */}
{category.children && category.children.length > 0 && (
<div className="ml-8 mt-2 space-y-1">
{category.children.map((child: any) => (
<Link
key={child.id}
href={`/category/${child.slug}?category_id=${child.id}`}
onClick={() => setIsCategoryOpen(false)}
className="block px-3 py-2 text-sm text-gray-600 hover:text-primary hover:bg-gray-50 rounded-lg transition-colors"
>
{child.name}
</Link>
))}
</div>
)}
</div>
))}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
{/* Local Auth Dialog */}
<AuthDialog isOpen={isLoginOpen} onClose={() => setIsLoginOpen(false)} />
</>
);
}

View File

@@ -0,0 +1,206 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import type React from "react";
import Link from "next/link";
import { User, Truck, Heart, Store, LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useCart, useFavorites, useOrders, useCartCount } from "@/lib/hooks";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "next-intl";
import { useLogout } from "@/lib/hooks/useAuth";
import {
CartIcon,
FavoriteIcon,
OrderIcon,
ProfileIcon,
} from "@/components/icons";
interface ActionButtonsProps {
isAuthenticated: boolean;
onAuthClick: () => void;
isLoading?: boolean;
locale?: string;
}
interface ActionButtonData {
icon: React.ReactNode;
label: string;
href?: string;
onClick?: () => void;
badgeCount?: number;
isLoading?: boolean;
}
export default function ActionButtons({
isAuthenticated,
onAuthClick,
isLoading: authLoading,
locale = "ru",
}: ActionButtonsProps) {
const t = useTranslations();
const { mutate: logout, isPending: isLoggingOut } = useLogout();
const router = useRouter();
const { data: cartData, isLoading: cartLoading } = useCart();
const { data: favoritesData, isLoading: favoritesLoading } = useFavorites();
const { data: ordersData, isLoading: ordersLoading } = useOrders();
// Calculate cart count from cart items array
const cartCount = useCartCount()
// Calculate favorites count
const favoritesCount = useMemo(() => {
if (!favoritesData) return 0;
return Array.isArray(favoritesData) ? favoritesData.length : 0;
}, [favoritesData]);
// Calculate orders count
const ordersCount = useMemo(() => {
if (!ordersData) return 0;
return Array.isArray(ordersData) ? ordersData.length : 0;
}, [ordersData]);
const handleLogout = () => {
logout(undefined, {
onSuccess: () => {
router.push(`/${locale}`);
router.refresh();
}
});
};
const buttons: ActionButtonData[] = useMemo(
() => [
{
icon: <Store />,
label: t("common.openStore"),
href: "/openStore",
},
{
icon: <OrderIcon />,
label: t("common.orders"),
href: "/orders",
badgeCount: ordersCount,
isLoading: ordersLoading,
},
{
icon: <FavoriteIcon />,
label: t("common.favorites"),
href: "/favorites",
badgeCount: favoritesCount,
isLoading: favoritesLoading,
},
{
icon: <CartIcon />,
label: t("common.cart"),
href: "/cart",
badgeCount: cartCount,
isLoading: cartLoading,
},
],
[
ordersCount,
ordersLoading,
favoritesCount,
favoritesLoading,
cartCount,
cartLoading,
t,
]
);
return (
<div className="hidden items-center gap-1 lg:flex">
{/* Profile/Login Button with Dropdown */}
{authLoading ? (
<div className="h-10 w-24 animate-pulse bg-gray-200 rounded" />
) : isAuthenticated ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="flex-col cursor-pointer gap-0.5 h-auto px-2 py-2"
>
<ProfileIcon />
<span className="text-xs text-gray-700">{t("profile")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => router.push(`/${locale}/me`)}>
<User className="mr-2 h-4 w-4" />
{t("profile")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
<LogOut className="mr-2 h-4 w-4" />
{isLoggingOut ? t("logging_out") : t("common.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button
variant="ghost"
size="sm"
className="flex-col cursor-pointer gap-0.5 h-auto px-2 py-2"
onClick={onAuthClick}
>
<ProfileIcon />
<span className="text-xs text-gray-700">{t("common.login")}</span>
</Button>
)}
{/* Other Action Buttons */}
{buttons.map((button, index) => (
<ActionButton key={index} {...button} />
))}
</div>
);
}
function ActionButton({
icon,
label,
href,
onClick,
badgeCount,
isLoading,
}: ActionButtonData) {
const buttonContent = (
<Button
variant="ghost"
size="sm"
className="relative flex-col gap-0.5 h-auto px-2 py-2"
onClick={onClick}
>
<div className="relative">
{icon}
{badgeCount !== undefined && badgeCount > 0 && (
<Badge
variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
>
{isLoading ? (
<Skeleton className="h-3 w-3 rounded-full" />
) : (
badgeCount
)}
</Badge>
)}
</div>
<span className="text-xs text-gray-700">{label}</span>
</Button>
);
if (href) {
return <Link href={href}>{buttonContent}</Link>;
}
return buttonContent;
}

View File

@@ -0,0 +1,197 @@
"use client";
import { useState, useCallback } from "react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import Logo from "@/public/logo.webp";
import { useLogin, useVerifyToken } from "@/lib/hooks/useAuth";
import { useTranslations } from "next-intl";
interface AuthDialogProps {
isOpen: boolean;
onClose: () => void;
}
export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
const [phone, setPhone] = useState("+993 ");
const [otp, setOtp] = useState("");
const [otpSent, setOtpSent] = useState(false);
const t = useTranslations();
const { mutate: login, isPending: isLoginLoading } = useLogin();
const { mutate: verifyToken, isPending: isVerifyLoading } = useVerifyToken();
const resetDialog = useCallback(() => {
setOtpSent(false);
setPhone("+993 ");
setOtp("");
onClose();
}, [onClose]);
const formatPhoneForBackend = (phoneNumber: string): string => {
return phoneNumber.replace(/^\+993\s*/, "").replace(/\s+/g, "");
};
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target.value;
const prefix = "+993 ";
if (input.length < prefix.length) {
setPhone(prefix);
return;
}
const digitsOnly = input.substring(prefix.length).replace(/\D/g, "");
const limitedDigits = digitsOnly.substring(0, 8);
let formattedPhone = prefix;
if (limitedDigits.length > 0) {
formattedPhone += limitedDigits.substring(0, 2);
if (limitedDigits.length > 2) {
formattedPhone += " " + limitedDigits.substring(2);
}
}
setPhone(formattedPhone);
};
const isPhoneValid = (): boolean => {
const phoneDigits = formatPhoneForBackend(phone);
return phoneDigits.length === 8;
};
const handleSendOtp = useCallback(() => {
if (!isPhoneValid()) {
toast.error(t("invalid_phone"));
return;
}
const phoneNumber = formatPhoneForBackend(phone);
login(
{ phone_number: parseInt(phoneNumber, 10) },
{
onSuccess: () => {
toast.success(t("code_sent"));
setOtpSent(true);
},
onError: (error: any) => {
toast.error(error?.response?.data?.message || t("error_occurred"));
},
}
);
}, [phone, login, t]);
const handleLogin = useCallback(() => {
if (otp.length < 4) {
toast.error(t("invalid_code"));
return;
}
const phoneNumber = formatPhoneForBackend(phone);
verifyToken(
{
phone_number: parseInt(phoneNumber, 10),
code: parseInt(otp, 10),
},
{
onSuccess: () => {
toast.success(t("login_success"));
resetDialog();
window.location.reload();
},
onError: (error: any) => {
toast.error(error?.response?.data?.message || t("wrong_code"));
},
}
);
}, [otp, phone, verifyToken, resetDialog, t]);
const handleKeyPress = useCallback(
(e: React.KeyboardEvent, action: () => void) => {
if (e.key === "Enter") {
action();
}
},
[]
);
return (
<Dialog open={isOpen} onOpenChange={resetDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center justify-center mb-4">
<div className="relative h-8 w-[180px]">
<Image src={Logo} alt="Logo" fill className="object-contain" />
</div>
</div>
<DialogTitle className="text-2xl text-center">
{t("common.enterPhone")}
</DialogTitle>
<p className="text-center text-sm text-gray-600">
{t("common.weWillSendCode")}
</p>
</DialogHeader>
<div className="space-y-4 mt-4">
<div>
<Input
type="tel"
placeholder="+993 61 097651"
value={phone}
onChange={handlePhoneChange}
className="h-12 rounded-xl"
onKeyDown={(e) => handleKeyPress(e, handleSendOtp)}
disabled={otpSent || isLoginLoading}
/>
<p className="text-xs text-gray-500 mt-1">{t("phone_format")}</p>
</div>
{otpSent && (
<Input
type="text"
placeholder={t("common.code")}
value={otp}
onChange={(e) =>
setOtp(e.target.value.replace(/\D/g, "").substring(0, 6))
}
className="h-12 rounded-xl"
onKeyDown={(e) => handleKeyPress(e, handleLogin)}
disabled={isVerifyLoading}
autoFocus
maxLength={6}
/>
)}
<Button
onClick={otpSent ? handleLogin : handleSendOtp}
className="w-full cursor-pointer h-12 rounded-xl font-bold text-base bg-[#005bff] hover:bg-[#0041c4]"
size="lg"
disabled={
isLoginLoading || isVerifyLoading || (!otpSent && !isPhoneValid())
}
>
{isLoginLoading
? t("sending")
: isVerifyLoading
? t("verifying")
: otpSent
? t("verify")
: t("common.send")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

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

View File

@@ -0,0 +1,80 @@
"use client";
import { useRouter, usePathname } from "next/navigation";
import Image from "next/image";
import { useLocale } from "next-intl";
import { useQueryClient } from "@tanstack/react-query";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import tm from "@/public/tm.png";
import ru from "@/public/ru.png";
interface Language {
code: string;
name: string;
flag: any;
}
const LANGUAGES: Language[] = [
{ code: "ru", name: "Russian", flag: ru },
{ code: "tm", name: "Turkmen", flag: tm },
];
export default function LanguageSelector() {
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
const handleLanguageChange = async (newLocale: string) => {
if (typeof window !== "undefined") {
(window as any).i18n = { language: newLocale };
}
queryClient.invalidateQueries();
const currentPath = pathname.replace(`/${locale}`, "");
router.replace(`/${newLocale}${currentPath}`);
};
return (
<Select value={locale} onValueChange={handleLanguageChange}>
<SelectTrigger className="w-[70px] md:h-10! flex items-center justify-center rounded-lg border-gray-300">
<SelectValue>
<FlagIcon locale={locale} />
</SelectValue>
</SelectTrigger>
<SelectContent>
{LANGUAGES.map((language) => (
<SelectItem key={language.code} value={language.code}>
<div className="flex items-center gap-2">
<FlagIcon locale={language.code} />
<span>{language.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function FlagIcon({ locale }: { locale: string }) {
const language = LANGUAGES.find((lang) => lang.code === locale);
if (!language) return null;
return (
<div className="relative h-5 w-7">
<Image
src={language.flag || "/placeholder.svg"}
alt={language.name}
fill
className="object-cover rounded"
/>
</div>
);
}

View File

@@ -0,0 +1,167 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Search, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useRouter } from "next/navigation";
import { useSearchProducts } from "@/features/search/hooks/useSearch";
import Image from "next/image";
import { SearchIcon } from "@/components/icons";
interface SearchBarProps {
isMobile: boolean;
searchPlaceholder: string;
isOpen?: boolean;
onClose?: () => void;
className?: string;
locale?: string;
}
export default function SearchBar({
isMobile,
searchPlaceholder,
isOpen,
onClose,
className = "",
locale = "ru",
}: SearchBarProps) {
const router = useRouter();
const [searchValue, setSearchValue] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [showResults, setShowResults] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const { data, isLoading } = useSearchProducts({ q: debouncedSearch });
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchValue);
}, 300);
return () => clearTimeout(timer);
}, [searchValue]);
useEffect(() => {
if (debouncedSearch && data?.data && data.data.length > 0) {
setShowResults(true);
} else {
setShowResults(false);
}
}, [debouncedSearch, data]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setShowResults(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSearch = (value: string) => {
setSearchValue(value);
};
const handleProductClick = (productId: number) => {
router.push(`/${locale}/product/${productId}`);
setSearchValue("");
setShowResults(false);
if (onClose) onClose();
};
const handleClearSearch = () => {
setSearchValue("");
setShowResults(false);
};
const SearchResults = () => {
if (!showResults || !data?.data) return null;
return (
<div className="absolute top-full left-0 right-0 mt-2 bg-white border rounded-xl shadow-lg max-h-[400px] overflow-y-auto z-50">
{data.data.map((product) => (
<button
key={product.id}
onClick={() => handleProductClick(product.id)}
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 shrink-0">
<Image
src={product.thumbnail}
alt={product.name}
fill
className="object-cover rounded-lg"
/>
</div>
<div className="flex-1 text-left">
<p className="font-medium text-sm line-clamp-2">{product.name}</p>
<p className="text-sm text-gray-600 mt-1">
{product.price_amount} TMT
</p>
<p className="text-xs text-gray-500">{product.brand.name}</p>
</div>
</button>
))}
</div>
);
};
if (isMobile) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="top-4 translate-y-0">
<DialogHeader>
<DialogTitle>{searchPlaceholder}</DialogTitle>
</DialogHeader>
<div className="relative" ref={searchRef}>
<Input
type="text"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => handleSearch(e.target.value)}
className="h-10 rounded-xl focus:border-[#005bff] focus-visible:border-[#005bff] focus-visible:ring-0 active:border-[#005bff]"
autoFocus
/>
{isLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-400" />
)}
<SearchResults />
</div>
</DialogContent>
</Dialog>
);
}
return (
<div className={`bg-[#005bff] rounded-xl flex items-center relative ${className}`} ref={searchRef}>
<div className="w-full relative">
<Input
type="text"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => handleSearch(e.target.value)}
className="border-[#005bff] w-full rounded-xl border-2 focus-visible:ring-0 bg-white px-2"
/>
{isLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-400" />
)}
</div>
<Button
size="icon"
className="h-auto hover:bg-[#005bff] cursor-pointer bg-transparent flex items-center mr-1.5 text-white"
>
<SearchIcon />
</Button>
<SearchResults />
</div>
);
}

53
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,53 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

46
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

60
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

92
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

241
components/ui/carousel.tsx Normal file
View File

@@ -0,0 +1,241 @@
"use client"
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-[#0041c4] dark:bg-input/30 data-[state=checked]:bg-[#005bff] data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-[#0041c4] focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

143
components/ui/dialog.tsx Normal file
View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

21
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

24
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-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 dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

187
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

139
components/ui/sheet.tsx Normal file
View File

@@ -0,0 +1,139 @@
"use client"
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

63
components/ui/slider.tsx Normal file
View File

@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-[#005bff] absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

47
components/ui/sonner.tsx Normal file
View File

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

66
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,66 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

59
context/AuthWrapper.tsx Normal file
View File

@@ -0,0 +1,59 @@
// components/AuthWrapper.tsx
"use client";
import { useEffect, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import { useAuthStatus, useGetGuestToken } from "@/lib/hooks/useAuth";
import { useUserProfile } from "@/features/profile/hooks/useUserProfile";
import Preloader from "@/components/PageLoader/PreLoader";
import TokenStorage from "@/lib/tokenStorage";
interface AuthWrapperProps {
children: ReactNode;
requireAuth?: boolean;
redirectTo?: string;
locale: string;
}
export default function AuthWrapper({
children,
requireAuth = false,
redirectTo,
locale,
}: AuthWrapperProps) {
const router = useRouter();
const { isAuthenticated, isLoading } = useAuthStatus();
const { mutate: getGuestToken, isPending: isGettingGuestToken } =
useGetGuestToken();
useUserProfile();
useEffect(() => {
if (isLoading) return;
if (!TokenStorage.hasAnyToken() && !isGettingGuestToken) {
getGuestToken();
}
}, [isLoading, getGuestToken, isGettingGuestToken]);
useEffect(() => {
if (isLoading || isGettingGuestToken) return;
if (requireAuth && !isAuthenticated) {
router.push(`/${locale}`);
return;
}
}, [
isAuthenticated,
isLoading,
requireAuth,
router,
locale,
isGettingGuestToken,
]);
if (isLoading || (requireAuth && !isAuthenticated)) {
return <Preloader />;
}
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>
);
}

54
eslint.config.mjs Normal file
View File

@@ -0,0 +1,54 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import tanstackPlugin from "@tanstack/eslint-plugin-query";
import importPlugin from "eslint-plugin-import";
export default defineConfig([
...nextVitals,
...nextTs,
// Custom rules for your e-commerce project
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: tsParser
},
plugins: {
"@typescript-eslint": tsPlugin,
"@tanstack/query": tanstackPlugin,
import: importPlugin
},
rules: {
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": ["error"],
"react-hooks/exhaustive-deps": "error",
"@tanstack/query/exhaustive-deps": "error",
"@tanstack/query/no-unstable-deps": "error",
"import/order": [
"error",
{
"groups": ["builtin", "external", "internal", "parent", "sibling", "index"],
"newlines-between": "always"
}
],
"import/no-default-export": "error",
"no-console": ["warn", { allow: ["warn", "error"] }]
}
},
globalIgnores([
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
])
]);

View File

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

View File

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

View File

@@ -0,0 +1,58 @@
"use client"
import { Truck, Warehouse } from "lucide-react"
import { Card } from "@/components/ui/card"
import { useTranslations } from "next-intl"
import type { DeliveryType } from "@/lib/types/api"
interface DeliveryTypeSelectorProps {
selectedType: DeliveryType
onSelect: (type: DeliveryType) => void
}
export default function DeliveryTypeSelector({
selectedType,
onSelect,
}: DeliveryTypeSelectorProps) {
const t = useTranslations()
const deliveryOptions: {
type: DeliveryType
label: string
icon: typeof Truck
}[] = [
{ type: "SELECTED_DELIVERY", label: t("delivery"), icon: Truck },
{ type: "PICK_UP", label: t("pickup"), icon: Warehouse },
]
return (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t("delivery_type")}</h3>
<div className="flex gap-2">
{deliveryOptions.map(({ type, label, icon: Icon }) => (
<Card
key={type}
className={`flex-1 cursor-pointer transition-all hover:shadow-md ${
selectedType === type
? "border-2 border-[#005bff] bg-blue-50"
: "border-2 border-gray-200"
}`}
onClick={() => onSelect(type)}
>
<div className="flex flex-col items-center justify-center p-4 gap-2">
<Icon
className={`h-8 w-8 ${
selectedType === type ? "text-[#005bff]" : "text-gray-600"
}`}
/>
<span className={`text-xs font-medium ${
selectedType === type ? "text-[#005bff]" : "text-gray-700"
}`}>
{label}
</span>
</div>
</Card>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { Button } from "@/components/ui/button";
import { ShoppingCart } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
export default function EmptyCart() {
const t=useTranslations();
const router=useRouter();
return (
<div className="flex min-h-[60vh] items-center justify-center px-4">
<div className="w-full max-w-md rounded-2xl bg-linear-to-br from-blue-50 to-white p-8 text-center shadow-lg">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-blue-100">
<ShoppingCart className="h-10 w-10 text-blue-600" />
</div>
<h2 className="mb-2 text-2xl font-semibold text-gray-900">
{t("cart_empty")}
</h2>
<p className="mb-6 text-sm text-gray-500">
{t("cart_empty_message")}
</p>
<Button onClick={()=>router.push("/")} className="w-full cursor-pointer rounded-xl bg-blue-600 px-6 py-3 text-sm font-medium text-white transition hover:bg-blue-700 active:scale-95">
{t("start_shopping")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,348 @@
"use client";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import DeliveryTypeSelector from "./DeliveryTypeSelector";
import { useTranslations } from "next-intl";
import type { DeliveryType, PaymentType, Province } from "@/lib/types/api";
import { useState } from "react";
interface OrderBillingItem {
title: string;
value: string;
}
interface OrderBilling {
body: OrderBillingItem[];
footer: {
title: string;
value: string;
};
}
interface OrderSummaryProps {
order: {
id: number;
billing: OrderBilling;
};
paymentType: PaymentType | null;
deliveryType: DeliveryType;
selectedRegion: string;
selectedProvince: number | null;
note: string;
regionGroups: Record<string, Province[]>;
availableRegions: string[];
paymentTypes: PaymentType[];
phone: string;
name: string;
lastName: string;
onPhoneChange: (phone: string) => void;
onNameChange: (name: string) => void;
onLastNameChange: (lastName: string) => void;
onPaymentTypeChange: (type: PaymentType) => void;
onDeliveryTypeChange: (type: DeliveryType) => void;
onRegionChange: (regionCode: string) => void;
onProvinceChange: (provinceId: number) => void;
onNoteChange: (note: string) => void;
onCompleteOrder: () => void;
isLoading: boolean;
}
export default function OrderSummary({
order,
paymentType,
deliveryType,
selectedRegion,
selectedProvince,
note,
regionGroups,
availableRegions,
paymentTypes,
phone,
name,
lastName,
onPhoneChange,
onNameChange,
onLastNameChange,
onPaymentTypeChange,
onDeliveryTypeChange,
onRegionChange,
onProvinceChange,
onNoteChange,
onCompleteOrder,
isLoading,
}: OrderSummaryProps) {
const t = useTranslations();
const [showValidation, setShowValidation] = useState(false);
const provincesForSelectedRegion = selectedRegion
? regionGroups[selectedRegion] || []
: [];
const phoneDigits = phone.replace(/\D/g, "");
const isPhoneValid = phoneDigits.length === 11;
const isFormValid =
selectedRegion &&
selectedProvince &&
paymentType &&
isPhoneValid &&
name.trim() !== "" &&
lastName.trim() !== "";
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target.value;
const prefix = "+993 ";
if (input.length < prefix.length) {
onPhoneChange(prefix);
return;
}
const digitsOnly = input.substring(prefix.length).replace(/\D/g, "");
const limitedDigits = digitsOnly.substring(0, 8);
let formattedPhone = prefix;
if (limitedDigits.length > 0) {
formattedPhone += limitedDigits.substring(0, 2);
if (limitedDigits.length > 2) {
formattedPhone += " " + limitedDigits.substring(2);
}
}
onPhoneChange(formattedPhone);
};
const handleCompleteOrderClick = () => {
setShowValidation(true);
if (isFormValid) {
onCompleteOrder();
}
};
return (
<Card className="w-full md:w-[380px] p-4 md:p-6 rounded-xl h-fit sticky top-20">
{/* Customer Information */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">
{t("customer_information")}
</h3>
<div className="space-y-5">
<div>
<Label className="text-sm font-medium mb-2 block">
{t("name")}
</Label>
<Input
type="text"
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder={t("name")}
className={`rounded-lg ${
showValidation && name.trim() === "" ? "border-red-500" : ""
}`}
/>
{showValidation && name.trim() === "" && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
<div>
<Label className="text-sm font-medium mb-2 block">
{t("last_name")}
</Label>
<Input
type="text"
value={lastName}
onChange={(e) => onLastNameChange(e.target.value)}
placeholder={t("last_name")}
className={`rounded-lg ${
showValidation && lastName.trim() === "" ? "border-red-500" : ""
}`}
/>
{showValidation && lastName.trim() === "" && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
<div>
<Label className="text-sm font-medium mb-2 block">
{t("phone")}
</Label>
<Input
type="tel"
value={phone}
onChange={handlePhoneChange}
placeholder="+993 61 097651"
className={`rounded-lg ${
showValidation && !isPhoneValid ? "border-red-500" : ""
}`}
/>
{showValidation && !isPhoneValid && (
<p className="text-xs text-red-500 mt-1">
{t("requiredField")}
</p>
)}
</div>
</div>
</div>
{/* Payment Type */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t("payment_type")}</h3>
<div className="flex gap-2">
{paymentTypes.map((type) => (
<Card
key={type.id}
className={`flex-1 cursor-pointer transition-all ${
paymentType?.id === type.id
? "border-2 border-[#005bff] bg-blue-50"
: showValidation && !paymentType
? "border-2 border-red-500"
: "border-2 border-gray-200"
}`}
onClick={() => onPaymentTypeChange(type)}
>
<div className="flex flex-col items-center justify-center p-4 gap-2">
<span
className={`text-xs font-medium ${
paymentType?.id === type.id ? "text-[#005bff]" : ""
}`}
>
{type.name}
</span>
</div>
</Card>
))}
</div>
{showValidation && !paymentType && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
{/* Region Selection */}
<div className="mb-6">
<Label className="text-lg font-semibold mb-3 block">
{t("choose_region")}
</Label>
<RadioGroup
value={selectedRegion}
onValueChange={(value) => {
onRegionChange(value);
onProvinceChange(null as any);
}}
className="flex flex-wrap gap-4"
>
{availableRegions.map((regionCode) => (
<div key={regionCode} className="flex items-center space-x-2">
<RadioGroupItem
value={regionCode}
id={`region-${regionCode}`}
className={`border-2 ${
showValidation && !selectedRegion
? "border-red-500"
: "border-gray-400"
} data-[state=checked]:border-[#005bff] data-[state=checked]:bg-white`}
/>
<Label
htmlFor={`region-${regionCode}`}
className="cursor-pointer uppercase"
>
{regionCode}
</Label>
</div>
))}
</RadioGroup>
{showValidation && !selectedRegion && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
{/* Province Selection */}
{selectedRegion && provincesForSelectedRegion.length > 0 && (
<div className="mb-6">
<Label className="text-lg font-semibold mb-3 block">
{t("choose_address")}
</Label>
<Select
value={selectedProvince?.toString() || ""}
onValueChange={(value) => onProvinceChange(parseInt(value))}
>
<SelectTrigger
className={`rounded-lg w-full ${
showValidation && !selectedProvince ? "border-red-500" : ""
}`}
>
<SelectValue placeholder={t("choose_address")} />
</SelectTrigger>
<SelectContent>
{provincesForSelectedRegion.map((province) => (
<SelectItem key={province.id} value={province.id.toString()}>
{province.name}
</SelectItem>
))}
</SelectContent>
</Select>
{showValidation && !selectedProvince && (
<p className="text-xs text-red-500 mt-1">{t("requiredField")}</p>
)}
</div>
)}
{/* Note */}
<div className="mb-6">
<Label className="text-lg font-semibold mb-3 block">{t("note")}</Label>
<Textarea
value={note}
onChange={(e) => onNoteChange(e.target.value)}
className="rounded-xl resize-none"
rows={3}
placeholder={t("note")}
/>
</div>
{/* Billing */}
<div className="space-y-2 mb-4">
{order.billing.body.map((item, index) => (
<div
key={index}
className="flex justify-between text-base font-medium"
>
<span>{item.title}:</span>
<span>{item.value}</span>
</div>
))}
</div>
<Separator className="my-4" />
<div className="flex justify-between items-center mb-6">
<span className="text-lg font-semibold">
{order.billing.footer.title}:
</span>
<span className="text-lg font-bold text-green-600">
{order.billing.footer.value}
</span>
</div>
<Button
onClick={handleCompleteOrderClick}
disabled={isLoading}
className="w-full rounded-lg cursor-pointer bg-[#005bff] hover:bg-[#004dcc] h-12 text-lg font-bold disabled:opacity-50"
size="lg"
>
{isLoading ? `${t("order")}...` : t("order")}
</Button>
</Card>
);
}

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

@@ -0,0 +1,25 @@
import { useQuery } from "@tanstack/react-query"
import { apiClient } from "@/lib/api"
interface Province {
id: number
region: string
name: string
}
interface ProvincesResponse {
message: string
data: Province[]
}
export function useRegions() {
return useQuery({
queryKey: ["regions"],
queryFn: async () => {
const response = await apiClient.get<ProvincesResponse>("/provinces")
return response.data.data
},
staleTime: 1000 * 60 * 60, // 1 hour
})
}

View File

@@ -0,0 +1,508 @@
import {
useQuery,
useMutation,
useQueryClient,
UseQueryOptions,
} from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import type { CartItem } from "@/lib/types/api";
import { useEffect } from "react";
interface CartResponse {
message: string;
data: CartItem[];
errorDetails?: string;
}
const pendingUpdates = new Map<number, number>();
let updateLock = false;
class CartEventEmitter {
private listeners: Set<() => void> = new Set();
subscribe(callback: () => void) {
this.listeners.add(callback);
return () => {
this.listeners.delete(callback);
};
}
emit() {
this.listeners.forEach((cb) => cb());
}
}
export const cartEvents = new CartEventEmitter();
function transformCartResponse(response: any): CartResponse {
if (
typeof response === "string" &&
(response.trim().startsWith("<!DOCTYPE") ||
response.trim().startsWith("<html"))
) {
return {
message: "error",
data: [],
errorDetails: "Server returned HTML instead of JSON.",
};
}
if (typeof response === "object") {
if (response.data) return response;
return { message: "success", data: [] };
}
if (typeof response === "string") {
try {
return JSON.parse(response);
} catch {
return { message: "error", data: [] };
}
}
return { message: "unknown", data: [] };
}
export function useCart(options?: Partial<UseQueryOptions<CartResponse>>) {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["cart"],
queryFn: async () => {
const response = await apiClient.get("/carts");
const transformed = transformCartResponse(response.data);
return transformed;
},
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: true,
staleTime: Infinity,
gcTime: 1000 * 60 * 5,
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
...options,
});
useEffect(() => {
const unsubscribe = cartEvents.subscribe(() => {
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "none",
});
});
return () => {
unsubscribe();
};
}, [queryClient]);
return query;
}
export function useAddToCart() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
productId,
quantity = 1,
}: {
productId: number;
quantity?: number;
}) => {
const params = new URLSearchParams({
product_id: String(productId),
product_quantity: String(quantity),
});
const response = await apiClient.post("/carts", params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (typeof response.data === "object" && response.data.data) {
return response.data;
}
if (typeof response.data === "string") {
try {
return JSON.parse(response.data);
} catch {
return { message: "success", data: "Added to cart" };
}
}
return { message: "success", data: "Added to cart" };
},
onMutate: async ({ productId, quantity }) => {
while (updateLock) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
updateLock = true;
await queryClient.cancelQueries({ queryKey: ["cart"] });
const previousCart = queryClient.getQueryData<CartResponse>(["cart"]);
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
if (!old) return old;
let updated = { ...old, data: [...old.data] };
pendingUpdates.forEach((pendingQty, pendingId) => {
const idx = updated.data.findIndex(
(item: any) => item.product?.id === pendingId
);
if (idx !== -1) {
updated.data[idx] = {
...updated.data[idx],
product_quantity: pendingQty,
};
}
});
const existingItem = updated.data.find(
(item: any) => item.product?.id === productId
);
if (existingItem) {
updated.data = updated.data.map((item: any) =>
item.product?.id === productId
? {
...item,
product_quantity: item.product_quantity + quantity,
}
: item
);
} else {
updated.data = [
...updated.data,
{
product: { id: productId },
product_quantity: quantity,
} as any,
];
}
const finalItem = updated.data.find(
(item: any) => item.product?.id === productId
);
if (finalItem) {
pendingUpdates.set(productId, finalItem.product_quantity);
}
return updated;
});
cartEvents.emit();
updateLock = false;
return { previousCart };
},
onError: (error, variables, context) => {
if (context?.previousCart) {
queryClient.setQueryData(["cart"], context.previousCart);
pendingUpdates.delete(variables.productId);
cartEvents.emit();
}
},
onSuccess: (data, variables) => {
pendingUpdates.delete(variables.productId);
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "active",
});
},
});
}
export function useRemoveFromCart() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (productId: number) => {
const params = new URLSearchParams({ product_id: String(productId) });
const response = await apiClient.patch("/carts", params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (typeof response.data === "object" && response.data.data) {
return response.data.data;
}
if (typeof response.data === "string") {
try {
const parsed = JSON.parse(response.data);
return parsed.data || [];
} catch {
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: () => {
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "active",
});
},
});
}
export function useCleanCart() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async () => {
const response = await apiClient.delete("/carts", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
if (typeof response.data === "object" && response.data.data) {
return response.data.data;
}
if (typeof response.data === "string") {
try {
const parsed = JSON.parse(response.data);
return parsed.data || [];
} catch {
return [];
}
}
return [];
},
onMutate: async () => {
while (updateLock) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
updateLock = true;
await queryClient.cancelQueries({ queryKey: ["cart"] });
const previousCart = queryClient.getQueryData<CartResponse>(["cart"]);
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
if (!old) return old;
pendingUpdates.clear();
return { ...old, data: [] };
});
cartEvents.emit();
updateLock = false;
return { previousCart };
},
onError: (error, variables, context) => {
if (context?.previousCart) {
queryClient.setQueryData(["cart"], context.previousCart);
cartEvents.emit();
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] });
},
});
}
export function useUpdateCartItemQuantity() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
productId,
quantity,
}: {
productId: number;
quantity: number;
}) => {
const params = new URLSearchParams({
product_id: String(productId),
product_quantity: String(quantity),
});
const response = await apiClient.post("/carts", params.toString(), {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
timeout: 15000,
});
if (typeof response.data === "object" && response.data.data) {
return response.data;
}
if (typeof response.data === "string") {
try {
return JSON.parse(response.data);
} catch {
return { message: "success", data: "Updated cart" };
}
}
return { message: "success", data: "Updated cart" };
},
onMutate: async ({ productId, quantity }) => {
while (updateLock) {
await new Promise((resolve) => setTimeout(resolve, 50));
}
updateLock = true;
await queryClient.cancelQueries({ queryKey: ["cart"] });
const previousCart = queryClient.getQueryData<CartResponse>(["cart"]);
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
if (!old) return old;
let updated = { ...old, data: [...old.data] };
pendingUpdates.forEach((pendingQty, pendingId) => {
const idx = updated.data.findIndex(
(item: any) => item.product?.id === pendingId
);
if (idx !== -1) {
updated.data[idx] = {
...updated.data[idx],
product_quantity: pendingQty,
};
}
});
updated.data = updated.data.map((item: any) =>
item.product?.id === productId
? { ...item, product_quantity: quantity }
: item
);
pendingUpdates.set(productId, quantity);
return updated;
});
cartEvents.emit();
updateLock = false;
return { previousCart };
},
onError: (error, variables, context) => {
if (context?.previousCart) {
queryClient.setQueryData(["cart"], context.previousCart);
pendingUpdates.delete(variables.productId);
cartEvents.emit();
}
throw error;
},
onSuccess: (data, variables) => {
pendingUpdates.delete(variables.productId);
queryClient.invalidateQueries({
queryKey: ["cart"],
refetchType: "none",
});
},
});
}
export function useCreateOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (payload: {
customer_name?: string;
customer_phone: number;
customer_address: string;
shipping_method: string;
payment_type_id: number;
delivery_time?: string;
delivery_at?: string;
region: string;
note?: string;
}) => {
const response = await apiClient.post("/orders", payload);
return response.data;
},
onSuccess: (data) => {
if (data && data.payment_url) {
window.open(data.payment_url, '_blank')?.focus();
}
pendingUpdates.clear();
queryClient.setQueryData<CartResponse>(["cart"], (old) => {
if (!old) return old;
return { ...old, data: [] };
});
cartEvents.emit();
queryClient.invalidateQueries({ queryKey: ["orders"] });
},
onError: (error: any) => {
console.error(
"Create order error:",
error.response?.data?.message || error.message
);
},
});
}
export function useCartCount() {
const { data } = useCart();
return (
data?.data?.reduce(
(sum: number, item: any) => sum + (item.product_quantity || 0),
0
) || 0
);
}

View File

@@ -0,0 +1,23 @@
import { useQuery } from "@tanstack/react-query"
import { apiClient } from "@/lib/api"
interface PaymentType {
id: number
name: string
}
interface PaymentTypesResponse {
message: string
data: PaymentType[]
}
export function usePaymentTypes() {
return useQuery({
queryKey: ["paymentTypes"],
queryFn: async () => {
const response = await apiClient.get<PaymentTypesResponse>("/order-payments")
return response.data.data
},
staleTime: 1000 * 60 * 60, // 1 hour
})
}

View File

@@ -0,0 +1,234 @@
import { useCallback } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import type { FilterBrand, FilterCategory } from "@/lib/types/api";
interface FiltersData {
categories: FilterCategory[];
brands: FilterBrand[];
}
interface CategoryFiltersProps {
filtersData: FiltersData | undefined;
selectedBrands: Set<number>;
selectedFilterCategories: Set<number>;
priceSort: "none" | "lowToHigh" | "highToLow";
priceRange: [number, number];
onBrandToggle: (brandId: number) => void;
onCategoryToggle: (categoryId: number) => void;
onPriceSortChange: (sortType: "none" | "lowToHigh" | "highToLow") => void;
onPriceChange: (values: number[]) => void;
onReset: () => void;
translations: {
category: string;
brands: string;
sort: string;
default: string;
price_low_to_high: string;
price_high_to_low: string;
price: string;
price_from: string;
price_to: string;
reset: string;
};
}
export default function CategoryFilters({
filtersData,
selectedBrands,
selectedFilterCategories,
priceSort,
priceRange,
onBrandToggle,
onCategoryToggle,
onPriceSortChange,
onPriceChange,
onReset,
translations,
}: CategoryFiltersProps) {
return (
<div className="space-y-6 mb-6">
{filtersData?.categories && filtersData.categories.length > 0 && (
<FilterSection title={translations.category}>
{filtersData.categories.map((category) => (
<CheckboxItem
key={category.id}
checked={selectedFilterCategories.has(category.id)}
onCheckedChange={() => onCategoryToggle(category.id)}
label={category.name}
/>
))}
</FilterSection>
)}
{filtersData?.brands && filtersData.brands.length > 0 && (
<FilterSection title={translations.brands}>
{filtersData.brands.map((brand) => (
<CheckboxItem
key={brand.id}
checked={selectedBrands.has(brand.id)}
onCheckedChange={() => onBrandToggle(brand.id)}
label={brand.name}
/>
))}
</FilterSection>
)}
{/* <FilterSection title={translations.sort}>
<RadioItem
name="sort"
checked={priceSort === "none"}
onChange={() => onPriceSortChange("none")}
label={translations.default}
/>
<RadioItem
name="sort"
checked={priceSort === "lowToHigh"}
onChange={() => onPriceSortChange("lowToHigh")}
label={translations.price_low_to_high}
/>
<RadioItem
name="sort"
checked={priceSort === "highToLow"}
onChange={() => onPriceSortChange("highToLow")}
label={translations.price_high_to_low}
/>
</FilterSection> */}
<PriceFilter
title={translations.price}
priceRange={priceRange}
onPriceChange={onPriceChange}
translations={{
from: translations.price_from,
to: translations.price_to,
}}
/>
<Button variant="outline" className="w-full rounded-lg cursor-pointer mb-6" onClick={onReset}>
{translations.reset}
</Button>
</div>
);
}
function FilterSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div>
<h3 className="text-lg font-semibold mb-3">{title}</h3>
<div className="space-y-2">{children}</div>
</div>
);
}
function CheckboxItem({
checked,
onCheckedChange,
label,
}: {
checked: boolean;
onCheckedChange: () => void;
label: string;
}) {
return (
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={checked} onCheckedChange={onCheckedChange} />
<span className="text-sm">{label}</span>
</label>
);
}
function RadioItem({
name,
checked,
onChange,
label,
}: {
name: string;
checked: boolean;
onChange: () => void;
label: string;
}) {
return (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={name}
checked={checked}
onChange={onChange}
className="w-4 h-4"
/>
<span>{label}</span>
</label>
);
}
function PriceFilter({
title,
priceRange,
onPriceChange,
translations,
}: {
title: string;
priceRange: [number, number];
onPriceChange: (values: number[]) => void;
translations: { from: string; to: string };
}) {
return (
<div>
<h3 className="text-lg font-semibold mb-3">{title}</h3>
<div className="space-y-4">
<div className="flex gap-2">
<div className="flex-1">
<Label htmlFor="price-from" className="text-xs mb-1">
{translations.from}
</Label>
<Input
id="price-from"
type="number"
value={priceRange[0]}
onChange={(e) =>
onPriceChange([parseInt(e.target.value) || 0, priceRange[1]])
}
className="rounded-lg"
/>
</div>
<div className="flex-1">
<Label htmlFor="price-to" className="text-xs mb-1">
{translations.to}
</Label>
<Input
id="price-to"
type="number"
value={priceRange[1]}
onChange={(e) =>
onPriceChange([
priceRange[0],
parseInt(e.target.value) || 10000,
])
}
className="rounded-lg"
/>
</div>
</div>
<Slider
min={0}
max={99999}
step={100}
value={priceRange}
onValueChange={onPriceChange}
className="mt-2"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { SlidersHorizontal, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
interface CategoryFiltersSheetProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
filterLabel: string;
closeLabel: string;
children: React.ReactNode;
}
export default function CategoryFiltersSheet({
isOpen,
onOpenChange,
filterLabel,
closeLabel,
children,
}: CategoryFiltersSheetProps) {
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetTrigger asChild>
<Button
className="bg-[#005bff] hover:bg-[#0041c4] sm:hidden fixed bottom-20 right-4 rounded-lg cursor-pointer font-bold gap-2 z-10 shadow-lg"
size="lg"
>
{filterLabel}
<SlidersHorizontal className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[290px] p-0">
<SheetHeader className="p-4 border-b">
<SheetTitle>{filterLabel}</SheetTitle>
<button
onClick={() => onOpenChange(false)}
className="absolute top-4 right-4 rounded-md cursor-pointer ring-offset-background transition-opacity hover:opacity-100"
>
<X className="h-4 w-4" />
<span className="sr-only">{closeLabel}</span>
</button>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-80px)] p-4">
{children}
</ScrollArea>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,395 @@
"use client";
import { useEffect, useState, useMemo, useCallback } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import {
useCategories,
useCategoryFilters,
useFilteredCategoryProducts,
} from "@/features/category/hooks/useCategories";
import { useTranslations } from "next-intl";
import type { Category, Product } from "@/lib/types/api";
import CategoryFilters from "./CategoryFilters";
import CategoryProductsGrid from "./CategoryProductsGrid";
import CategoryFiltersSheet from "./CategoryFiltersSheet";
import ErrorPage from "@/components/ErrorPage";
interface CategoryPageClientProps {
params: { locale: string; slug: string };
}
export default function CategoryPageClient({
params,
}: CategoryPageClientProps) {
const { slug } = params;
const t = useTranslations();
const [isSheetOpen, setIsSheetOpen] = useState(false);
const {
data: categoriesData,
isLoading: categoriesLoading,
isError: categoriesError
} = useCategories();
const selectedCategory = useMemo(() => {
if (!categoriesData || !slug) return null;
const findBySlug = (categories: Category[]): Category | null => {
for (const category of categories) {
if (category.slug === slug) return category;
if (category.children) {
const found = findBySlug(category.children);
if (found) return found;
}
}
return null;
};
return findBySlug(categoriesData);
}, [categoriesData, slug]);
// State management
const [currentPage, setCurrentPage] = useState(1);
const [allProducts, setAllProducts] = useState<Product[]>([]);
const [priceSort, setPriceSort] = useState<
"none" | "lowToHigh" | "highToLow"
>("none");
const [priceRange, setPriceRange] = useState<[number, number]>([0, 10000]);
const [selectedBrands, setSelectedBrands] = useState<Set<number>>(new Set());
const [selectedFilterCategories, setSelectedFilterCategories] = useState<
Set<number>
>(new Set());
// Fetch filters
const {
data: filtersData,
isLoading: filtersLoading,
isError: filtersError
} = useCategoryFilters(selectedCategory?.id, {
enabled: !!selectedCategory,
});
// Build filter params
const filterParams = useMemo(() => {
const params: any = {
page: currentPage,
limit: 6,
};
if (selectedBrands.size > 0) {
params.brands = Array.from(selectedBrands);
}
if (selectedFilterCategories.size > 0) {
params.categories = Array.from(selectedFilterCategories);
}
params.min_price = priceRange[0];
params.max_price = priceRange[1];
return params;
}, [currentPage, selectedBrands, selectedFilterCategories, priceRange]);
// Fetch filtered products
const {
data: productsData,
isFetching,
isError: productsError
} = useFilteredCategoryProducts(
selectedCategory?.id?.toString() || "",
filterParams,
{ enabled: !!selectedCategory }
);
// Reset on category change
useEffect(() => {
if (selectedCategory) {
setAllProducts([]);
setCurrentPage(1);
setSelectedBrands(new Set());
setSelectedFilterCategories(new Set());
setPriceRange([0, 10000]);
setPriceSort("none");
}
}, [selectedCategory?.id]);
// Update products list
useEffect(() => {
if (productsData?.data) {
setAllProducts((prev) => {
if (currentPage === 1) {
return productsData.data;
}
const existingIds = new Set(prev.map((p) => p.id));
const newProducts = productsData.data.filter(
(p: Product) => !existingIds.has(p.id)
);
if (newProducts.length === 0) {
return prev;
}
return [...prev, ...newProducts];
});
}
}, [productsData?.data, currentPage]);
const hasMore = useMemo(() => {
if (!productsData?.pagination) return false;
if (productsData.pagination.next_page_url) return true;
if (
productsData.pagination.current_page &&
productsData.pagination.last_page
) {
return (
productsData.pagination.current_page < productsData.pagination.last_page
);
}
if (productsData.pagination.hasMorePages !== undefined) {
return productsData.pagination.hasMorePages;
}
return false;
}, [productsData?.pagination]);
const loadMoreData = useCallback(() => {
if (!hasMore || isFetching) return;
setCurrentPage((prev) => prev + 1);
}, [hasMore, isFetching]);
const sortedProducts = useMemo(() => {
const products = [...allProducts];
if (priceSort === "lowToHigh") {
return products.sort(
(a, b) =>
parseFloat(a.price_amount || "0") - parseFloat(b.price_amount || "0")
);
}
if (priceSort === "highToLow") {
return products.sort(
(a, b) =>
parseFloat(b.price_amount || "0") - parseFloat(a.price_amount || "0")
);
}
return products;
}, [allProducts, priceSort]);
// Filter handlers
const handleBrandToggle = useCallback((brandId: number) => {
setSelectedBrands((prev) => {
const newSet = new Set(prev);
newSet.has(brandId) ? newSet.delete(brandId) : newSet.add(brandId);
return newSet;
});
setCurrentPage(1);
setAllProducts([]);
}, []);
const handleCategoryToggle = useCallback((categoryId: number) => {
setSelectedFilterCategories((prev) => {
const newSet = new Set(prev);
newSet.has(categoryId)
? newSet.delete(categoryId)
: newSet.add(categoryId);
return newSet;
});
setCurrentPage(1);
setAllProducts([]);
}, []);
const handlePriceChange = useCallback((values: number[]) => {
setPriceRange([values[0], values[1]]);
setCurrentPage(1);
setAllProducts([]);
}, []);
const handlePriceSortChange = useCallback(
(sortType: "none" | "lowToHigh" | "highToLow") => {
setPriceSort(sortType);
},
[]
);
const resetFilters = useCallback(() => {
setSelectedBrands(new Set());
setSelectedFilterCategories(new Set());
setPriceRange([0, 10000]);
setPriceSort("none");
setCurrentPage(1);
setAllProducts([]);
}, []);
const filterTranslations = useMemo(
() => ({
category: t("category"),
brands: t("brands"),
sort: t("sort"),
default: t("default"),
price_low_to_high: t("price_low_to_high"),
price_high_to_low: t("price_high_to_low"),
price: t("price"),
price_from: t("price_from"),
price_to: t("price_to"),
reset: t("reset"),
}),
[t]
);
// ERROR STATE
if (categoriesError || productsError || filtersError) {
return <ErrorPage />;
}
// LOADING STATE
if (categoriesLoading) {
return (
<div className="flex flex-col mx-auto max-w-[1504px] px-2 md:px-4 lg:px-6 pb-12">
{/* Title Skeleton */}
<Skeleton className="h-16 w-full rounded-t-lg mb-0 bg-white" />
<div className="flex p-2 md:p-4 gap-4 bg-white rounded-b-lg mt-0">
{/* Desktop Filters Skeleton */}
<div className="hidden sm:block w-[280px] shrink-0 border-r px-4 space-y-6">
<Skeleton className="h-8 w-32" />
<div className="space-y-2">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-8 w-32" />
<div className="space-y-2">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
{/* Products Grid Skeleton */}
<div className="flex-1">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="w-full aspect-square rounded-lg" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-6 w-1/2" />
</div>
))}
</div>
</div>
</div>
</div>
);
}
// CATEGORY NOT FOUND
if (!selectedCategory) {
return <div className="text-center py-8">{t("category_not_found")}</div>;
}
return (
<div className="flex flex-col mx-auto max-w-[1504px] px-2 md:px-4 lg:px-6 pb-12">
<h2 className="p-4 text-3xl font-bold pb-6 rounded-t-lg mb-0 bg-white">
{selectedCategory.name}
</h2>
<div className="flex p-2 md:p-4 gap-4 bg-white rounded-b-lg">
{/* Desktop Filters Sidebar */}
<div className="hidden sm:block w-[280px] shrink-0 border-r px-4">
<ScrollArea className="h-auto">
{filtersLoading ? (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
) : (
<CategoryFilters
filtersData={filtersData}
selectedBrands={selectedBrands}
selectedFilterCategories={selectedFilterCategories}
priceSort={priceSort}
priceRange={priceRange}
onBrandToggle={handleBrandToggle}
onCategoryToggle={handleCategoryToggle}
onPriceSortChange={handlePriceSortChange}
onPriceChange={handlePriceChange}
onReset={resetFilters}
translations={filterTranslations}
/>
)}
</ScrollArea>
</div>
{/* Products Grid */}
<div className="flex-1 bg-white rounded-lg mb-6">
<CategoryProductsGrid
products={sortedProducts}
hasMore={hasMore}
onLoadMore={loadMoreData}
isFetching={isFetching}
translations={{
loading: t("common.loading"),
no_results: t("no_results"),
}}
/>
</div>
</div>
{/* Mobile Filters Sheet */}
<CategoryFiltersSheet
isOpen={isSheetOpen}
onOpenChange={setIsSheetOpen}
filterLabel={t("filter")}
closeLabel={t("close")}
>
{filtersLoading ? (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
) : (
<CategoryFilters
filtersData={filtersData}
selectedBrands={selectedBrands}
selectedFilterCategories={selectedFilterCategories}
priceSort={priceSort}
priceRange={priceRange}
onBrandToggle={handleBrandToggle}
onCategoryToggle={handleCategoryToggle}
onPriceSortChange={handlePriceSortChange}
onPriceChange={handlePriceChange}
onReset={resetFilters}
translations={filterTranslations}
/>
)}
</CategoryFiltersSheet>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import InfiniteScroll from "react-infinite-scroll-component";
import ProductCard from "@/features/home/components/ProductCard";
import type { Product } from "@/lib/types/api";
interface CategoryProductsGridProps {
products: Product[];
hasMore: boolean;
onLoadMore: () => void;
isFetching?: boolean;
translations: {
loading: string;
no_results: string;
};
}
export default function CategoryProductsGrid({
products,
hasMore,
onLoadMore,
isFetching = false,
translations,
}: CategoryProductsGridProps) {
if (products.length === 0 && !isFetching) {
return (
<div className="text-center py-8 text-gray-500">
{translations.no_results}
</div>
);
}
return (
<InfiniteScroll
dataLength={products.length}
next={onLoadMore}
hasMore={hasMore}
scrollThreshold={0.8}
style={{ overflow: "visible" }}
loader={
<div className="flex justify-center py-4">
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
<span>{translations.loading}</span>
</div>
</div>
}
endMessage={
products.length > 0 && !hasMore ? (
<div className="text-center py-4 text-gray-500 text-sm">
</div>
) : null
}
>
<div className="bg-white rounded-lg grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{products.map((product) => (
<ProductCard
key={product.id}
id={product.id}
name={product.name}
price={
product.price_amount ? parseFloat(product.price_amount) : null
}
struct_price_text={`${product.price_amount} TMT`}
images={[product.media?.[0]?.images_400x400]}
stock={product.stock}
button={true}
/>
))}
</div>
{isFetching && products.length === 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 mt-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="bg-gray-200 h-48 rounded-lg mb-2" />
<div className="bg-gray-200 h-4 rounded w-3/4 mb-2" />
<div className="bg-gray-200 h-4 rounded w-1/2" />
</div>
))}
</div>
)}
</InfiniteScroll>
);
}

View File

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

View File

@@ -0,0 +1,222 @@
import { useQuery } from "@tanstack/react-query"
import { apiClient } from "@/lib/api"
import type { Category, Product, PaginatedResponse, FiltersResponse, ProductFilters } from "@/lib/types/api"
// Get all categories as tree
export function useCategories(options?: { enabled?: boolean }) {
return useQuery({
queryKey: ["categories"],
queryFn: async () => {
const response = await apiClient.get<PaginatedResponse<Category>>("/categories", {
params: { type: "tree" },
})
return response.data.data || response.data
},
enabled: options?.enabled !== false,
staleTime: 1000 * 60 * 30, // 30 minutes
})
}
// Get single category by ID
export function useCategory(id: number | string, options?: { enabled?: boolean }) {
return useQuery({
queryKey: ["category", id],
queryFn: async () => {
const response = await apiClient.get<Category>(`/categories/${id}`)
return response.data
},
enabled: options?.enabled !== false && !!id,
staleTime: 1000 * 60 * 15,
})
}
// Get products for a single category with pagination
export function useCategoryProducts(
categoryId: number | string,
options?: {
enabled?: boolean
page?: number
limit?: number
}
) {
return useQuery({
queryKey: ["category", categoryId, "products", options?.page, options?.limit],
queryFn: async () => {
const response = await apiClient.get<PaginatedResponse<Product>>(
`/categories/${categoryId}/products`,
{
params: {
page: options?.page || 1,
per_page: options?.limit
},
}
)
return {
data: response.data.data || [],
pagination: response.data.pagination || {}
}
},
enabled: options?.enabled !== false && !!categoryId,
})
}
// Get ALL products from category and its children - NO pagination (for initial load)
export function useAllCategoryProducts(
category: Category | undefined,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ["category", category?.id, "all-products"],
queryFn: async () => {
if (!category) return []
const fetchProducts = async (categoryId: number) => {
const response = await apiClient.get<PaginatedResponse<Product>>(
`/categories/${categoryId}/products`
)
return response.data.data || []
}
let allProducts = await fetchProducts(category.id)
if (category.children && category.children.length > 0) {
for (const child of category.children) {
const childProducts = await fetchProducts(child.id)
allProducts = [...allProducts, ...childProducts]
}
}
return allProducts
},
enabled: options?.enabled !== false && !!category,
})
}
export function useCategoryFilters(
categoryId: number | string | undefined,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ["category-filters", categoryId],
queryFn: async () => {
const response = await apiClient.get<FiltersResponse>(
"/filters",
{
params: { category_id: categoryId },
}
)
return response.data.data
},
enabled: options?.enabled !== false && !!categoryId,
staleTime: 1000 * 60 * 15,
})
}
// Get filtered category products
export function useFilteredCategoryProducts(
categoryId: number | string,
filters: ProductFilters,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ["category", categoryId, "filtered-products", filters],
queryFn: async () => {
const params: Record<string, any> = {
page: filters.page || 1,
per_page: filters.limit || 6,
}
if (filters.brands && filters.brands.length > 0) {
params.brands = filters.brands.join(',')
}
if (filters.categories && filters.categories.length > 0) {
params.categories = filters.categories.join(',')
}
if (filters.min_price !== undefined) {
params.min_price = filters.min_price
}
if (filters.max_price !== undefined) {
params.max_price = filters.max_price
}
const response = await apiClient.get<PaginatedResponse<Product>>(
`/categories/${categoryId}/products`,
{ params }
)
return {
data: response.data.data || [],
pagination: response.data.pagination || {}
}
},
enabled: options?.enabled !== false && !!categoryId,
})
}
// Get products from category and children WITH pagination (mimics RTK getAllCategoryProductsPaginated)
export function useAllCategoryProductsPaginated(
category: Category | undefined,
options?: {
enabled?: boolean
page?: number
limit?: number
}
) {
const page = options?.page || 1
const per_page = options?.limit || 6
return useQuery({
queryKey: ["category", category?.id, "paginated-products", page, per_page],
queryFn: async () => {
if (!category) {
return {
data: [],
pagination: {
currentPage: page,
hasMorePages: false
}
}
}
const categoryIds = [category.id]
if (category.children && category.children.length > 0) {
category.children.forEach((child) => categoryIds.push(child.id))
}
const perCategoryLimit = Math.ceil(per_page / categoryIds.length)
const hasMoreByCategory: Record<number, boolean> = {}
let allPageProducts: Product[] = []
for (const categoryId of categoryIds) {
const response = await apiClient.get<PaginatedResponse<Product>>(
`/categories/${categoryId}/products`,
{
params: {
page,
per_page: perCategoryLimit
}
}
)
if (response.data.data) {
allPageProducts = [...allPageProducts, ...response.data.data]
hasMoreByCategory[categoryId] = !!response.data.pagination?.next_page_url
}
}
const hasMorePages = Object.values(hasMoreByCategory).some((hasMore) => hasMore)
return {
data: allPageProducts,
pagination: {
currentPage: page,
hasMorePages
}
}
},
enabled: options?.enabled !== false && !!category,
})
}

View File

@@ -0,0 +1,233 @@
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import type { FilterBrand, FilterCategory } from "@/lib/types/api";
interface FiltersData {
categories: FilterCategory[];
brands: FilterBrand[];
}
interface CollectionFiltersProps {
filtersData: FiltersData | undefined;
selectedBrands: Set<number>;
selectedCategories: Set<number>;
priceSort: "none" | "lowToHigh" | "highToLow";
priceRange: [number, number];
onBrandToggle: (brandId: number) => void;
onCategoryToggle: (categoryId: number) => void;
onPriceSortChange: (sortType: "none" | "lowToHigh" | "highToLow") => void;
onPriceChange: (values: number[]) => void;
onReset: () => void;
translations: {
category: string;
brands: string;
sort: string;
default: string;
price_low_to_high: string;
price_high_to_low: string;
price: string;
price_from: string;
price_to: string;
reset: string;
};
}
export default function CollectionFilters({
filtersData,
selectedBrands,
selectedCategories,
priceSort,
priceRange,
onBrandToggle,
onCategoryToggle,
onPriceSortChange,
onPriceChange,
onReset,
translations,
}: CollectionFiltersProps) {
return (
<div className="space-y-6 mb-6">
{filtersData?.categories && filtersData.categories.length > 0 && (
<FilterSection title={translations.category}>
{filtersData.categories.map((category) => (
<CheckboxItem
key={category.id}
checked={selectedCategories.has(category.id)}
onCheckedChange={() => onCategoryToggle(category.id)}
label={category.name}
/>
))}
</FilterSection>
)}
{filtersData?.brands && filtersData.brands.length > 0 && (
<FilterSection title={translations.brands}>
{filtersData.brands.map((brand) => (
<CheckboxItem
key={brand.id}
checked={selectedBrands.has(brand.id)}
onCheckedChange={() => onBrandToggle(brand.id)}
label={brand.name}
/>
))}
</FilterSection>
)}
<FilterSection title={translations.sort}>
<RadioItem
name="sort"
checked={priceSort === "none"}
onChange={() => onPriceSortChange("none")}
label={translations.default}
/>
<RadioItem
name="sort"
checked={priceSort === "lowToHigh"}
onChange={() => onPriceSortChange("lowToHigh")}
label={translations.price_low_to_high}
/>
<RadioItem
name="sort"
checked={priceSort === "highToLow"}
onChange={() => onPriceSortChange("highToLow")}
label={translations.price_high_to_low}
/>
</FilterSection>
<PriceFilter
title={translations.price}
priceRange={priceRange}
onPriceChange={onPriceChange}
translations={{
from: translations.price_from,
to: translations.price_to,
}}
/>
<Button variant="outline" className="w-full rounded-lg cursor-pointer mb-6" onClick={onReset}>
{translations.reset}
</Button>
</div>
);
}
function FilterSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div>
<h3 className="text-lg font-semibold mb-3">{title}</h3>
<div className="space-y-2">{children}</div>
</div>
);
}
function CheckboxItem({
checked,
onCheckedChange,
label,
}: {
checked: boolean;
onCheckedChange: () => void;
label: string;
}) {
return (
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={checked} onCheckedChange={onCheckedChange} />
<span className="text-sm">{label}</span>
</label>
);
}
function RadioItem({
name,
checked,
onChange,
label,
}: {
name: string;
checked: boolean;
onChange: () => void;
label: string;
}) {
return (
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name={name}
checked={checked}
onChange={onChange}
className="w-4 h-4"
/>
<span>{label}</span>
</label>
);
}
function PriceFilter({
title,
priceRange,
onPriceChange,
translations,
}: {
title: string;
priceRange: [number, number];
onPriceChange: (values: number[]) => void;
translations: { from: string; to: string };
}) {
return (
<div>
<h3 className="text-lg font-semibold mb-3">{title}</h3>
<div className="space-y-4">
<div className="flex gap-2">
<div className="flex-1">
<Label htmlFor="price-from" className="text-xs mb-1">
{translations.from}
</Label>
<Input
id="price-from"
type="number"
value={priceRange[0]}
onChange={(e) =>
onPriceChange([parseInt(e.target.value) || 0, priceRange[1]])
}
className="rounded-lg"
/>
</div>
<div className="flex-1">
<Label htmlFor="price-to" className="text-xs mb-1">
{translations.to}
</Label>
<Input
id="price-to"
type="number"
value={priceRange[1]}
onChange={(e) =>
onPriceChange([
priceRange[0],
parseInt(e.target.value) || 10000,
])
}
className="rounded-lg"
/>
</div>
</div>
<Slider
min={0}
max={99999}
step={100}
value={priceRange}
onValueChange={onPriceChange}
className="mt-2"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { SlidersHorizontal, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
interface CollectionFiltersSheetProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
filterLabel: string;
closeLabel: string;
children: React.ReactNode;
}
export default function CollectionFiltersSheet({
isOpen,
onOpenChange,
filterLabel,
closeLabel,
children,
}: CollectionFiltersSheetProps) {
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetTrigger asChild>
<Button
className="bg-[#005bff] hover:bg-[#0041c4] sm:hidden fixed bottom-20 right-4 rounded-lg cursor-pointer font-bold gap-2 z-10 shadow-lg"
size="lg"
>
{filterLabel}
<SlidersHorizontal className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[290px] p-0">
<SheetHeader className="p-4 border-b">
<SheetTitle>{filterLabel}</SheetTitle>
<button
onClick={() => onOpenChange(false)}
className="absolute top-4 right-4 rounded-md cursor-pointer ring-offset-background transition-opacity hover:opacity-100"
>
<X className="h-4 w-4" />
<span className="sr-only">{closeLabel}</span>
</button>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-80px)] p-4">
{children}
</ScrollArea>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,385 @@
"use client";
import { useEffect, useState, useMemo, useCallback } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import {
useCollections,
useCollectionFilters,
useFilteredCollectionProducts,
} from "@/features/collections/hooks/useCollections";
import { useTranslations } from "next-intl";
import type { Product } from "@/lib/types/api";
import CollectionFilters from "./CollectionFilters";
import CollectionProductsGrid from "./CollectionProductsGrid";
import CollectionFiltersSheet from "./CollectionFiltersSheet";
import ErrorPage from "@/components/ErrorPage";
interface CollectionPageClientProps {
params: { locale: string; slug: string };
}
export default function CollectionPageClient({
params,
}: CollectionPageClientProps) {
const { slug } = params;
const t = useTranslations();
const [isSheetOpen, setIsSheetOpen] = useState(false);
const {
data: collectionsData,
isLoading: collectionsLoading,
isError: collectionsError,
} = useCollections();
const selectedCollection = useMemo(() => {
if (!collectionsData || !slug) return null;
return collectionsData.find((col) => col.slug === slug);
}, [collectionsData, slug]);
// State management
const [currentPage, setCurrentPage] = useState(1);
const [allProducts, setAllProducts] = useState<Product[]>([]);
const [priceSort, setPriceSort] = useState<
"none" | "lowToHigh" | "highToLow"
>("none");
const [priceRange, setPriceRange] = useState<[number, number]>([0, 10000]);
const [selectedBrands, setSelectedBrands] = useState<Set<number>>(new Set());
const [selectedCategories, setSelectedCategories] = useState<Set<number>>(
new Set()
);
// Fetch filters
const {
data: filtersData,
isLoading: filtersLoading,
isError: filtersError,
} = useCollectionFilters(selectedCollection?.id, {
enabled: !!selectedCollection,
});
// Build filter params
const filterParams = useMemo(() => {
const params: any = {
page: currentPage,
limit: 6,
};
if (selectedBrands.size > 0) {
params.brands = Array.from(selectedBrands);
}
if (selectedCategories.size > 0) {
params.categories = Array.from(selectedCategories);
}
params.min_price = priceRange[0];
params.max_price = priceRange[1];
return params;
}, [currentPage, selectedBrands, selectedCategories, priceRange]);
// Fetch filtered products
const {
data: productsData,
isFetching,
isError: productsError,
} = useFilteredCollectionProducts(
selectedCollection?.id?.toString() || "",
filterParams,
{ enabled: !!selectedCollection }
);
// Reset on collection change
useEffect(() => {
if (selectedCollection) {
setAllProducts([]);
setCurrentPage(1);
setSelectedBrands(new Set());
setSelectedCategories(new Set());
setPriceRange([0, 10000]);
setPriceSort("none");
}
}, [selectedCollection?.id]);
// Update products list
useEffect(() => {
if (productsData?.data) {
setAllProducts((prev) => {
if (currentPage === 1) {
return productsData.data;
}
const existingIds = new Set(prev.map((p) => p.id));
const newProducts = productsData.data.filter(
(p: Product) => !existingIds.has(p.id)
);
if (newProducts.length === 0) {
return prev;
}
return [...prev, ...newProducts];
});
}
}, [productsData?.data, currentPage]);
const hasMore = useMemo(() => {
return !!productsData?.pagination?.next_page_url;
}, [productsData]);
const loadMoreData = useCallback(() => {
if (!hasMore || isFetching) return;
setCurrentPage((prev) => prev + 1);
}, [hasMore, isFetching]);
// Client-side sorting
const sortedProducts = useMemo(() => {
const products = [...allProducts];
if (priceSort === "lowToHigh") {
return products.sort(
(a, b) =>
parseFloat(a.price_amount || "0") - parseFloat(b.price_amount || "0")
);
}
if (priceSort === "highToLow") {
return products.sort(
(a, b) =>
parseFloat(b.price_amount || "0") - parseFloat(a.price_amount || "0")
);
}
return products;
}, [allProducts, priceSort]);
// Filter handlers
const handleBrandToggle = useCallback((brandId: number) => {
setSelectedBrands((prev) => {
const newSet = new Set(prev);
newSet.has(brandId) ? newSet.delete(brandId) : newSet.add(brandId);
return newSet;
});
setCurrentPage(1);
setAllProducts([]);
}, []);
const handleCategoryToggle = useCallback((categoryId: number) => {
setSelectedCategories((prev) => {
const newSet = new Set(prev);
newSet.has(categoryId)
? newSet.delete(categoryId)
: newSet.add(categoryId);
return newSet;
});
setCurrentPage(1);
setAllProducts([]);
}, []);
const handlePriceChange = useCallback((values: number[]) => {
setPriceRange([values[0], values[1]]);
setCurrentPage(1);
setAllProducts([]);
}, []);
const handlePriceSortChange = useCallback(
(sortType: "none" | "lowToHigh" | "highToLow") => {
setPriceSort(sortType);
},
[]
);
const resetFilters = useCallback(() => {
setSelectedBrands(new Set());
setSelectedCategories(new Set());
setPriceRange([0, 10000]);
setPriceSort("none");
setCurrentPage(1);
setAllProducts([]);
}, []);
const filterTranslations = useMemo(
() => ({
category: t("category"),
brands: t("brands"),
sort: t("sort"),
default: t("default"),
price_low_to_high: t("price_low_to_high"),
price_high_to_low: t("price_high_to_low"),
price: t("price"),
price_from: t("price_from"),
price_to: t("price_to"),
reset: t("reset"),
}),
[t]
);
// ERROR STATE
if (collectionsError || productsError || filtersError) {
return <ErrorPage />;
}
// LOADING STATE
if (collectionsLoading) {
return (
<div className="flex flex-col mx-auto max-w-[1504px] px-2 md:px-4 lg:px-6 pb-12">
{/* Title Skeleton */}
<Skeleton className="h-16 w-full rounded-t-lg mb-0 bg-white" />
<div className="flex p-2 md:p-4 gap-4 bg-white rounded-b-lg mt-0">
{/* Desktop Filters Skeleton */}
<div className="hidden sm:block w-[280px] shrink-0 border-r px-4 space-y-6">
<Skeleton className="h-8 w-32" />
<div className="space-y-2">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-8 w-32" />
<div className="space-y-2">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-8 w-32" />
<div className="space-y-2">
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
{/* Products Grid Skeleton */}
<div className="flex-1">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="w-full aspect-square rounded-lg" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-6 w-1/2" />
</div>
))}
</div>
</div>
</div>
</div>
);
}
// COLLECTION NOT FOUND
if (!selectedCollection) {
return <div className="text-center py-8">{t("collection_not_found")}</div>;
}
return (
<div className="flex flex-col mx-auto max-w-[1504px] px-2 md:px-4 lg:px-6 pb-12">
<h2 className="p-4 text-3xl font-bold pb-6 rounded-t-lg mb-0 bg-white">
{selectedCollection.name}
</h2>
<div className="flex p-2 md:p-4 gap-4 bg-white rounded-b-lg">
{/* Desktop Filters Sidebar */}
<div className="hidden sm:block w-[280px] shrink-0 border-r px-4">
<ScrollArea className="h-auto">
{filtersLoading ? (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
) : (
<CollectionFilters
filtersData={filtersData}
selectedBrands={selectedBrands}
selectedCategories={selectedCategories}
priceSort={priceSort}
priceRange={priceRange}
onBrandToggle={handleBrandToggle}
onCategoryToggle={handleCategoryToggle}
onPriceSortChange={handlePriceSortChange}
onPriceChange={handlePriceChange}
onReset={resetFilters}
translations={filterTranslations}
/>
)}
</ScrollArea>
</div>
{/* Products Grid */}
<div className="flex-1 bg-white rounded-lg mb-6">
<CollectionProductsGrid
products={sortedProducts}
hasMore={hasMore}
onLoadMore={loadMoreData}
isFetching={isFetching}
translations={{
loading: t("common.loading"),
no_results: t("no_results"),
}}
/>
</div>
</div>
{/* Mobile Filters Sheet */}
<CollectionFiltersSheet
isOpen={isSheetOpen}
onOpenChange={setIsSheetOpen}
filterLabel={t("filter")}
closeLabel={t("close")}
>
{filtersLoading ? (
<div className="space-y-6">
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-6 w-24" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
<Skeleton className="h-5 w-full" />
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
) : (
<CollectionFilters
filtersData={filtersData}
selectedBrands={selectedBrands}
selectedCategories={selectedCategories}
priceSort={priceSort}
priceRange={priceRange}
onBrandToggle={handleBrandToggle}
onCategoryToggle={handleCategoryToggle}
onPriceSortChange={handlePriceSortChange}
onPriceChange={handlePriceChange}
onReset={resetFilters}
translations={filterTranslations}
/>
)}
</CollectionFiltersSheet>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import InfiniteScroll from "react-infinite-scroll-component";
import ProductCard from "@/features/home/components/ProductCard";
import type { Product } from "@/lib/types/api";
interface CollectionProductsGridProps {
products: Product[];
hasMore: boolean;
isFetching?: boolean;
onLoadMore: () => void;
translations: {
loading: string;
no_results: string;
};
}
export default function CollectionProductsGrid({
products,
hasMore,
onLoadMore,
isFetching = false,
translations,
}: CollectionProductsGridProps) {
if (products.length === 0 && !isFetching) {
return (
<div className="text-center py-8 text-gray-500">
{translations.no_results}
</div>
);
}
return (
<InfiniteScroll
dataLength={products.length}
next={onLoadMore}
hasMore={hasMore}
scrollThreshold={0.8}
style={{ overflow: "visible" }}
loader={
<div className="flex justify-center py-4">
<div className="flex items-center gap-2">
<div className="w-5 h-5 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
<span>{translations.loading}</span>
</div>
</div>
}
endMessage={
products.length > 0 && !hasMore ? (
<div className="text-center py-4 text-gray-500 text-sm"></div>
) : null
}
>
<div className="bg-white rounded-lg grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{products.map((product) => (
<ProductCard
key={product.id}
id={product.id}
name={product.name}
price={
product.price_amount ? parseFloat(product.price_amount) : null
}
struct_price_text={`${product.price_amount} TMT`}
images={[product.media?.[0]?.images_400x400]}
stock={product.stock}
button={true}
/>
))}
</div>
{isFetching && products.length === 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 mt-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="bg-gray-200 h-48 rounded-lg mb-2" />
<div className="bg-gray-200 h-4 rounded w-3/4 mb-2" />
<div className="bg-gray-200 h-4 rounded w-1/2" />
</div>
))}
</div>
)}
</InfiniteScroll>
);
}

View File

@@ -0,0 +1,161 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import type {
Collection,
Product,
PaginatedResponse,
FiltersResponse,
ProductFilters,
} from "@/lib/types/api";
// Get all collections
export function useCollections(options?: { enabled?: boolean }) {
return useQuery({
queryKey: ["collections"],
queryFn: async () => {
const response = await apiClient.get<PaginatedResponse<Collection>>(
"/collections"
);
return response.data.data || response.data;
},
enabled: options?.enabled !== false,
staleTime: 1000 * 60 * 30, // 30 minutes
});
}
// Get single collection by ID
export function useCollection(
id: number | string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ["collection", id],
queryFn: async () => {
const response = await apiClient.get<Collection>(`/collections/${id}`);
return response.data;
},
enabled: options?.enabled !== false && !!id,
staleTime: 1000 * 60 * 15,
});
}
// Get products for a collection with pagination
export function useCollectionProducts(
collectionId: number | string,
options?: {
enabled?: boolean;
page?: number;
limit?: number;
}
) {
return useQuery({
queryKey: [
"collection",
collectionId,
"products",
options?.page,
options?.limit,
],
queryFn: async () => {
const response = await apiClient.get<PaginatedResponse<Product>>(
`/collections/${collectionId}/products`,
{
params: {
page: options?.page || 1,
per_page: options?.limit,
},
}
);
return {
data: response.data.data || [],
pagination: response.data.pagination || {},
};
},
enabled: options?.enabled !== false && !!collectionId,
});
}
// Get filters for collection products
export function useCollectionFilters(
collectionId: number | string | undefined,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ["collection-filters", collectionId],
queryFn: async () => {
const response = await apiClient.get<FiltersResponse>("/filters", {
params: { collection_id: collectionId },
});
return response.data.data;
},
enabled: options?.enabled !== false && !!collectionId,
staleTime: 1000 * 60 * 15,
});
}
// Get filtered collection products
export function useFilteredCollectionProducts(
collectionId: number | string,
filters: ProductFilters,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ["collection", collectionId, "filtered-products", filters],
queryFn: async () => {
const params: Record<string, any> = {
page: filters.page || 1,
per_page: filters.limit || 6,
};
if (filters.brands && filters.brands.length > 0) {
params.brands = filters.brands.join(",");
}
if (filters.categories && filters.categories.length > 0) {
params.categories = filters.categories.join(",");
}
if (filters.min_price !== undefined) {
params.min_price = filters.min_price;
}
if (filters.max_price !== undefined) {
params.max_price = filters.max_price;
}
const response = await apiClient.get<PaginatedResponse<Product>>(
`/collections/${collectionId}/products`,
{ params }
);
return {
data: response.data.data || [],
pagination: response.data.pagination || {},
};
},
enabled: options?.enabled !== false && !!collectionId,
});
}
// Check if collection has products
export function useCheckCollectionHasProducts(
collectionId: number | string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ["collection", collectionId, "has-products"],
queryFn: async () => {
const response = await apiClient.get<PaginatedResponse<Product>>(
`/collections/${collectionId}/products`,
{
params: { limit: 1 },
}
);
return {
hasProducts: response.data.data && response.data.data.length > 0,
};
},
enabled: options?.enabled !== false && !!collectionId,
staleTime: 1000 * 60 * 5,
});
}

View File

@@ -0,0 +1,30 @@
import { Button } from "@/components/ui/button";
import { Heart } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
export default function EmptyFavorites() {
const t=useTranslations();
const router=useRouter();
return (
<div className="flex min-h-[60vh] items-center justify-center px-4">
<div className="w-full max-w-md rounded-2xl bg-linear-to-br from-blue-50 to-white p-8 text-center shadow-lg">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-blue-100">
<Heart className="h-10 w-10 text-blue-600" />
</div>
<h2 className="mb-2 text-2xl font-semibold text-gray-900">
{t("favorites_empty")}
</h2>
<p className="mb-6 text-sm text-gray-500">
{t("favorites_empty_message")}
</p>
<Button onClick={()=>router.push("/")} className="w-full rounded-lg cursor-pointer bg-blue-600 px-6 py-3 text-sm font-medium text-white transition hover:bg-blue-700 active:scale-95">
{t("start_shopping")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,203 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import type { Favorite } from "@/lib/types/api";
interface FavoritesResponse {
data?: Favorite[];
[key: string]: any;
}
function transformFavoritesResponse(response: any): Favorite[] {
if (typeof response === "object" && response.data) {
return response.data;
}
if (typeof response === "string") {
try {
const parsed = JSON.parse(response);
return parsed.data || [];
} catch {
return [];
}
}
return [];
}
// Fetch ALL favorite products (handle pagination on backend)
async function fetchAllFavorites(): Promise<Favorite[]> {
const allFavorites: Favorite[] = [];
let currentPage = 1;
let hasMorePages = true;
let lastError: Error | null = null;
while (hasMorePages) {
try {
const response = await apiClient.get("/favorites", {
params: { page: currentPage, perPage: 100 },
});
const favorites = transformFavoritesResponse(response.data);
allFavorites.push(...favorites);
const pagination = response.data?.pagination;
if (pagination?.next_page_url) {
currentPage++;
} else {
hasMorePages = false;
}
} catch (error) {
if (currentPage === 1) {
throw error;
}
lastError = error as Error;
hasMorePages = false;
}
}
if (allFavorites.length === 0 && lastError) {
throw lastError;
}
return allFavorites;
}
// Get all favorites with automatic pagination
export function useFavorites() {
return useQuery({
queryKey: ["favorites"],
queryFn: fetchAllFavorites,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10, // Keep in cache for 10 minutes
retry: 1,
});
}
// Get favorite product IDs as Set for O(1) lookup - ALWAYS loads favorites first
export function useFavoriteIds() {
const { data: favorites, isLoading } = useFavorites();
// Return Set with IDs, empty Set while loading
return {
favoriteIds: new Set(favorites?.map((fav) => fav.product.id) || []),
isLoading,
};
}
// Check if product is favorited - with loading state
export function useIsFavorite(productId: number) {
const { favoriteIds, isLoading } = useFavoriteIds();
return {
isFavorite: favoriteIds.has(productId),
isLoading,
};
}
// Toggle favorite (add/remove) with optimistic updates
export function useToggleFavorite() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
productId,
isFavorite,
}: {
productId: number;
isFavorite: boolean;
}) => {
const formData = new URLSearchParams({
product_id: productId.toString(),
});
await apiClient.post("/favorites", formData, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return { productId, wasAdded: !isFavorite };
},
onMutate: async ({ productId, isFavorite }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ["favorites"] });
// Snapshot previous
const previousFavorites = queryClient.getQueryData<Favorite[]>([
"favorites",
]);
// Optimistically update
queryClient.setQueryData<Favorite[]>(["favorites"], (old = []) => {
if (isFavorite) {
// Remove from favorites
return old.filter((fav) => fav.product.id !== productId);
}
// For add, we'll refetch to get full product data
return old;
});
return { previousFavorites };
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previousFavorites) {
queryClient.setQueryData(["favorites"], context.previousFavorites);
}
},
onSettled: () => {
// Refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: ["favorites"] });
},
});
}
// Add to favorites
export function useAddToFavorites() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (productId: number) => {
const formData = new URLSearchParams({
product_id: productId.toString(),
});
await apiClient.post("/favorites", formData, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return productId;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["favorites"] });
},
});
}
// Remove from favorites
export function useRemoveFromFavorites() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (productId: number) => {
const formData = new URLSearchParams({
product_id: productId.toString(),
});
await apiClient.post("/favorites", formData, {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});
return productId;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["favorites"] });
},
});
}

View File

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

View File

@@ -0,0 +1,81 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import type { Category } from "@/lib/types/api";
import { Skeleton } from "@/components/ui/skeleton";
type Props = {
categories: Category[] | undefined;
isLoading: boolean;
isError: boolean;
locale: string;
title: string;
};
export default function CategoryGrid({
categories,
isLoading,
isError,
locale,
title,
}: Props) {
if (isError) {
return (
<section className="bg-white rounded-2xl shadow-sm p-6">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<p className="text-red-600">
Failed to load categories. Please try again.
</p>
</section>
);
}
if (isLoading) {
return (
<section className="bg-white rounded-2xl shadow-sm p-6">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{Array.from({ length: 10 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="w-full h-36 rounded-lg" />
<Skeleton className="h-4 w-full" />
</div>
))}
</div>
</section>
);
}
return (
<section className="bg-white rounded-2xl shadow-sm p-6">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{categories?.map((cat) => (
<Link
key={cat.id}
href={`/${locale}/category/${cat.slug}?category_id=${cat.id}`}
>
<Card className="hover:shadow-md border-none shadow-none p-0 gap-2 transition-all cursor-pointer">
<div className="relative w-full h-36 overflow-hidden rounded-lg">
<Image
src={
cat.media[0]?.thumbnail || cat.media?.[0]?.images_400x400
}
alt={cat.name}
fill
className="object-contain"
/>
</div>
<CardContent className="py-2">
<p className="text-sm font-medium text-gray-800 truncate text-center">
{cat.name}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,126 @@
"use client";
import { useLocale, useTranslations } from "next-intl";
import { useState } from "react";
import InfiniteScroll from "react-infinite-scroll-component";
import HeroCarousel from "./Carousel";
import CategoryGrid from "./CategoryGrid";
import CollectionSection from "./ProductGrid";
import { Skeleton } from "@/components/ui/skeleton";
import {
useCategories,
useCarousels,
useCollections,
useFavorites,
} from "@/lib/hooks";
export default function HomePage() {
const locale = useLocale();
const t = useTranslations("common");
const [visibleCount, setVisibleCount] = useState(10);
const {
data: categories,
isLoading: categoriesLoading,
isError: categoriesError,
} = useCategories();
const { data: carousels, isLoading: carouselsLoading } = useCarousels();
const {
data: collections,
isLoading: collectionsLoading,
isError: collectionsError,
} = useCollections();
useFavorites();
const loadMore = () => {
if (collections && visibleCount < collections.length) {
setVisibleCount((prev) => Math.min(prev + 10, collections.length));
}
};
const carouselItems =
carousels?.map((c) => ({
title: c.title || "",
image: c.image || c.thumbnail,
url: c.link || null,
})) || [];
const visibleCollections = collections?.slice(0, visibleCount) || [];
const hasMore = collections ? visibleCount < collections.length : false;
return (
<div className="px-2 md:px-4 lg:px-6 pt-4 pb-12 space-y-8 max-w-[1504px] mx-auto">
{carouselsLoading ? (
<section className=" bg-white rounded-2xl overflow-hidden">
<Skeleton className="w-full h-[200px] sm:h-[300px] md:h-[496px]" />
</section>
) : (
carouselItems.length > 0 && <HeroCarousel items={carouselItems} />
)}
<CategoryGrid
categories={categories}
isLoading={categoriesLoading}
isError={categoriesError}
locale={locale}
title={t("categories")}
/>
{collectionsError ? (
<section className="bg-white rounded-2xl shadow-sm p-6">
<p className="text-red-600">
Failed to load collections. Please try again.
</p>
</section>
) : collectionsLoading ? (
<div className="space-y-8">
{Array.from({ length: 3 }).map((_, i) => (
<section key={i} className="bg-white rounded-2xl shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-6 w-6 rounded-full" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4">
{Array.from({ length: 10 }).map((_, j) => (
<div key={j} className="space-y-2">
<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>
</section>
))}
</div>
) : (
<InfiniteScroll
dataLength={visibleCollections.length}
next={loadMore}
hasMore={hasMore}
loader={
<div className="text-center py-8">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent" />
<p className="text-gray-500 mt-2">{t("loading")}</p>
</div>
}
endMessage={
<div className="text-center py-8">
<p className="text-gray-600"> {t("all_collections_loaded")}</p>
</div>
}
scrollThreshold={0.8}
>
<div className="space-y-8">
{visibleCollections.map((collection) => (
<CollectionSection
key={collection.id}
collection={collection}
locale={locale}
/>
))}
</div>
</InfiniteScroll>
)}
</div>
);
}

View File

@@ -0,0 +1,452 @@
"use client";
import { useState, useEffect, useRef, useCallback, MouseEvent } from "react";
import { useRouter } from "next/navigation";
import { Heart, ShoppingCart, Plus, Minus, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
type CarouselApi,
} from "@/components/ui/carousel";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useToggleFavorite, useIsFavorite } from "@/lib/hooks";
import {
useAddToCart,
useUpdateCartItemQuantity,
useCart,
} from "@/features/cart/hooks/useCart";
import { useTranslations } from "next-intl";
type ProductCardProps = {
id: number;
name: string;
price: number | null;
struct_price_text: string;
discount?: number | null;
discount_text?: string | null;
images: string[];
labels?: { text: string; bg_color: string }[];
price_color?: string;
height?: number;
width?: number;
button?: boolean;
stock?: number;
};
export default function ProductCard({
id,
name,
price,
struct_price_text,
images,
labels = [],
price_color = "#005bff",
height = 360,
width = 280,
button = false,
stock,
}: ProductCardProps) {
const router = useRouter();
const t = useTranslations();
const { isFavorite, isLoading: isFavoriteLoading } = useIsFavorite(id);
const { mutate: toggleFavorite, isPending: isFavoriteToggling } =
useToggleFavorite();
const addToCartMutation = useAddToCart();
const updateCartMutation = useUpdateCartItemQuantity();
const { data: cartData, refetch: refetchCart } = useCart();
const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0);
const [localQuantity, setLocalQuantity] = useState(1);
const [isSyncing, setIsSyncing] = useState(false);
const [showStockModal, setShowStockModal] = useState(false);
const autoplayRef = useRef<NodeJS.Timeout | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isRequestInFlightRef = useRef<boolean>(false);
const pendingQuantityRef = useRef<number | null>(null);
const hasMultipleImages = images.length > 1;
const cartItem = cartData?.data?.find((item: any) => item.product?.id === id);
const isInCart = !!cartItem;
const isOutOfStock = stock === 0;
const availableStock = stock || 999;
useEffect(() => {
if (!api) return;
setCurrent(api.selectedScrollSnap());
const onSelect = () => setCurrent(api.selectedScrollSnap());
api.on("select", onSelect);
return () => {
api.off("select", onSelect);
};
}, [api]);
useEffect(() => {
if (!api || !hasMultipleImages) return;
autoplayRef.current = setInterval(() => {
api.canScrollNext() ? api.scrollNext() : api.scrollTo(0);
}, 3000);
return () => {
if (autoplayRef.current) clearInterval(autoplayRef.current);
};
}, [api, hasMultipleImages]);
useEffect(() => {
setLocalQuantity(cartItem?.product_quantity || 1);
}, [cartItem]);
const syncToServer = useCallback(
async (quantity: number) => {
if (isRequestInFlightRef.current) {
pendingQuantityRef.current = quantity;
return;
}
isRequestInFlightRef.current = true;
setIsSyncing(true);
try {
await updateCartMutation.mutateAsync({ productId: id, quantity });
await refetchCart();
if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current;
pendingQuantityRef.current = null;
setTimeout(() => syncToServer(nextQuantity), 100);
}
} catch (error) {
console.error("Sync failed:", error);
setLocalQuantity(cartItem?.product_quantity || 1);
toast.error("Failed to update quantity", {
description: "Please try again",
});
} finally {
isRequestInFlightRef.current = false;
setIsSyncing(false);
}
},
[id, updateCartMutation, cartItem, refetchCart]
);
useEffect(() => {
if (!isInCart || localQuantity === (cartItem?.product_quantity || 1))
return;
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = setTimeout(() => {
syncToServer(localQuantity);
}, 800);
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
};
}, [localQuantity, isInCart, cartItem, syncToServer]);
const handleFavorite = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
toggleFavorite(
{ productId: id, isFavorite },
{
onSuccess: (data) =>
toast.success(
data.wasAdded ? t("added_to_favorites") : t("removed_from_favorites")
),
onError: () => toast.error("Error. Try again"),
}
);
},
[id, isFavorite, toggleFavorite]
);
const handleAddToCart = useCallback(
async (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
if (localQuantity > availableStock) {
setShowStockModal(true);
return;
}
setIsSyncing(true);
try {
await addToCartMutation.mutateAsync({
productId: id,
quantity: localQuantity,
});
toast.success(t("added_to_cart"), {
description: `${name} ${t("added_to_cart_description")}`,
});
} catch (error) {
console.error("Add to cart error:", error);
toast.error(t("add_to_cart_failed"));
} finally {
setIsSyncing(false);
}
},
[id, name, localQuantity, availableStock, addToCartMutation]
);
const handleQuantityChange = useCallback(
(e: MouseEvent<HTMLButtonElement>, delta: number) => {
e.preventDefault();
e.stopPropagation();
const newQuantity = localQuantity + delta;
if (newQuantity < 1) return;
if (newQuantity > availableStock) {
setShowStockModal(true);
return;
}
setLocalQuantity(newQuantity);
},
[localQuantity, availableStock]
);
const handleCardClick = useCallback((e: MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
// Prevent navigation if clicking on buttons or interactive elements
if (
target.closest("button") ||
target.closest('[data-carousel-control="true"]') ||
target.closest('[role="dialog"]')
) {
e.preventDefault();
e.stopPropagation();
return;
}
// Programmatic navigation
e.preventDefault();
router.push(`/product/${id}`);
}, [router, id]);
const handleNavClick = (e: MouseEvent, action: () => void) => {
e.preventDefault();
e.stopPropagation();
action();
};
return (
<>
<div
onClick={handleCardClick}
className="flex justify-center cursor-pointer"
>
<Card
className="relative gap-2 border-none shadow-none p-0 w-full overflow-hidden rounded-2xl"
style={{ height, maxWidth: width }}
>
<div className="relative w-full h-[260px] group">
<Carousel
opts={{ align: "start", loop: true, watchDrag: false }}
setApi={setApi}
className="w-full h-full"
>
<CarouselContent className="h-[260px] ml-0">
{images.map((image, idx) => (
<CarouselItem key={idx} className="h-[260px] pl-0">
<div className="h-full flex items-center justify-center">
<img
src={image}
alt={`${name} - ${idx + 1}`}
className="max-w-full max-h-full object-contain"
draggable="false"
/>
</div>
</CarouselItem>
))}
</CarouselContent>
{hasMultipleImages && (
<>
<CarouselPrevious
data-carousel-control="true"
className="absolute left-2 opacity-0 group-hover:opacity-100 transition-opacity z-20"
onClick={(e) => handleNavClick(e, () => api?.scrollPrev())}
/>
<CarouselNext
data-carousel-control="true"
className="absolute right-2 opacity-0 group-hover:opacity-100 transition-opacity z-20"
onClick={(e) => handleNavClick(e, () => api?.scrollNext())}
/>
</>
)}
</Carousel>
<button
onClick={handleFavorite}
disabled={isFavoriteToggling || isFavoriteLoading}
className="absolute top-3 cursor-pointer right-3 z-10 rounded-full bg-white/80 p-2 hover:bg-white transition-all disabled:opacity-50"
>
{isFavoriteLoading ? (
<div className="w-5 h-5 border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin" />
) : (
<Heart
className={`w-5 h-5 ${
isFavorite ? "text-[#005bff] fill-[#005bff]" : "text-gray-700"
}`}
/>
)}
</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 cursor-pointer transition-all ${
idx === current ? "w-6 bg-white" : "w-1.5 bg-white/60"
}`}
/>
))}
</div>
)}
{labels.length > 0 && (
<div className="absolute top-2 left-2 flex flex-col gap-1 z-10">
{labels.map((label, idx) => (
<Badge
key={idx}
className="text-white text-[10px] font-bold uppercase rounded-r-md"
style={{ backgroundColor: label.bg_color }}
>
{label.text}
</Badge>
))}
</div>
)}
{isOutOfStock && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center z-10">
<Badge variant="secondary" className="text-sm font-bold">
{t("outOfStock")}
</Badge>
</div>
)}
</div>
<CardContent className="p-0 space-y-1">
<p
className="text-sm mx-2 font-medium"
style={{ color: price_color }}
>
{struct_price_text}
</p>
<p className="text-black text-sm font-semibold leading-normal truncate mx-2">
{name}
</p>
</CardContent>
{button && !isOutOfStock && (
<div className="px-1">
{!isInCart ? (
<Button
onClick={handleAddToCart}
disabled={isSyncing}
className="w-full rounded-lg cursor-pointer gap-2 bg-[#005bff] hover:bg-[#0041c4]"
size="sm"
>
{isSyncing ? (
<>
{t("adding")}
</>
) : (
<>
<ShoppingCart className="h-4 w-4" />
{t("add_to_cart")}
</>
)}
</Button>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={(e) => handleQuantityChange(e, -1)}
disabled={isSyncing || localQuantity <= 1}
className="rounded-lg cursor-pointer h-9 w-9 shrink-0"
>
<Minus className="h-4 w-4" />
</Button>
<div className="flex-1 text-center font-semibold text-sm border rounded-lg h-9 flex items-center justify-center bg-white relative">
{localQuantity}
</div>
<Button
variant="outline"
size="icon"
onClick={(e) => handleQuantityChange(e, 1)}
disabled={isSyncing}
className="rounded-lg cursor-pointer h-9 w-9 shrink-0"
>
<Plus className="h-4 w-4 text-[#005bff]" />
</Button>
</div>
)}
</div>
)}
</Card>
</div>
<Dialog open={showStockModal} onOpenChange={setShowStockModal}>
<DialogContent className="sm:max-w-md" onClick={(e) => e.stopPropagation()}>
<DialogHeader>
<div className="flex items-center justify-center mb-4">
<div className="rounded-full bg-orange-100 p-3">
<AlertTriangle className="h-6 w-6 text-orange-600" />
</div>
</div>
<DialogTitle className="text-center text-xl">
{t("stock_limit_title")}
</DialogTitle>
<DialogDescription className="text-center text-base pt-2">
{t("stock_limit_message", {
product: name,
stock: availableStock,
})}
</DialogDescription>
</DialogHeader>
<div className="flex justify-center mt-4">
<Button
onClick={(e) => {
e.stopPropagation();
setShowStockModal(false);
}}
className="w-full rounded-lg cursor-pointer"
>
{t("understood")}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { useRouter } from "next/navigation";
import { ChevronRight } from "lucide-react";
import ProductCard from "@/features/home/components/ProductCard";
import { useCollectionProducts } from "@/features/collections/hooks/useCollections";
import type { Collection } from "@/lib/types/api";
import { Skeleton } from "@/components/ui/skeleton";
type Props = {
collection: Collection;
locale: string;
};
export default function CollectionSection({ collection, locale }: Props) {
const router = useRouter();
const {
data: productsData,
isLoading,
isError,
} = useCollectionProducts(collection.id);
const handleTitleClick = () => {
router.push(`/collections/${collection.slug}`);
};
if (isLoading) {
return (
<section className="bg-white rounded-2xl shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-6 w-6 rounded-full" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4">
{Array.from({ length: 10 }).map((_, i) => (
<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>
</section>
);
}
if (isError) return null;
// Hide section if no products
if (!productsData?.data || productsData.data.length === 0) {
return null;
}
const displayProducts = productsData?.data.slice(0, 10) || [];
return (
<section className="bg-white rounded-2xl shadow-sm p-6">
<div
className="flex items-center justify-between mb-4 cursor-pointer group"
onClick={handleTitleClick}
>
<h2 className="text-xl font-semibold group-hover:text-blue-600 transition-colors">
{collection.name}
</h2>
<ChevronRight className="w-6 h-6 text-gray-400 group-hover:text-blue-600 group-hover:translate-x-1 transition-all" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4">
{displayProducts.map((product) => {
const allImages = product.media
?.map(
(m) =>
m.images_800x800 ||
m.images_720x720 ||
m.images_400x400 ||
m.thumbnail
)
.filter(Boolean) || ["/placeholder-product.jpg"];
const formattedPrice = product.price_amount
? `${parseFloat(product.price_amount).toFixed(2)} TMT`
: "Price not available";
return (
<ProductCard
key={product.id}
id={product.id}
name={product.name}
price={
product.price_amount ? parseFloat(product.price_amount) : null
}
struct_price_text={formattedPrice}
images={allImages}
labels={[]}
price_color="#0059ff"
height={360}
width={250}
/>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,150 @@
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import type { Collection, Product, PaginatedResponse } from "@/lib/types/api";
// Get ALL collections (fetch all pages)
export function useCollections(options?: { enabled?: boolean }) {
return useQuery({
queryKey: ["collections"],
queryFn: async () => {
const allCollections: Collection[] = [];
let currentPage = 1;
let hasMorePages = true;
while (hasMorePages) {
const response = await apiClient.get<PaginatedResponse<Collection>>(
"/collections",
{ params: { page: currentPage, perPage: 50 } }
);
const collections = response.data.data || [];
allCollections.push(...collections);
// Check if there are more pages
const pagination = response.data.pagination;
if (pagination && pagination.next_page_url) {
currentPage++;
} else {
hasMorePages = false;
}
}
return allCollections;
},
enabled: options?.enabled !== false,
staleTime: 1000 * 60 * 30, // 30 minutes
});
}
// Get single collection by ID
export function useCollection(
id: number | string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ["collection", id],
queryFn: async () => {
const response = await apiClient.get<Collection>(`/collections/${id}`);
return response.data;
},
enabled: options?.enabled !== false && !!id,
staleTime: 1000 * 60 * 15,
});
}
// Get ALL products for a collection (fetch all pages)
export function useCollectionProducts(
collectionId: number | string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ["collection", collectionId, "products"],
queryFn: async () => {
const allProducts: Product[] = [];
let currentPage = 1;
let hasMorePages = true;
while (hasMorePages) {
const response = await apiClient.get<PaginatedResponse<Product>>(
`/collections/${collectionId}/products`,
{ params: { page: currentPage, perPage: 50 } }
);
const products = response.data.data || [];
allProducts.push(...products);
// Check if there are more pages
const pagination = response.data.pagination;
if (pagination && pagination.next_page_url) {
currentPage++;
} else {
hasMorePages = false;
}
}
return {
data: allProducts,
isEmpty: allProducts.length === 0,
};
},
enabled: options?.enabled !== false && !!collectionId,
});
}
// Check if collection has products (limit=1 for efficiency)
export function useCollectionHasProducts(
collectionId: number | string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: ["collection", collectionId, "has-products"],
queryFn: async () => {
const response = await apiClient.get<PaginatedResponse<Product>>(
`/collections/${collectionId}/products`,
{ params: { perPage: 20 } }
);
return {
hasProducts: response.data.data && response.data.data.length > 0,
};
},
enabled: options?.enabled !== false && !!collectionId,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
// Get collection products with infinite scroll (recommended for UI)
export function useCollectionProductsInfinite(
collectionId: number | string,
options?: { enabled?: boolean; perPage?: number }
) {
const perPage = options?.perPage || 6;
return useInfiniteQuery({
queryKey: ["collection", collectionId, "products-infinite", perPage],
queryFn: async ({ pageParam = 1 }) => {
const response = await apiClient.get<PaginatedResponse<Product>>(
`/collections/${collectionId}/products`,
{
params: {
page: pageParam,
perPage,
},
}
);
return {
data: response.data.data || [],
pagination: response.data.pagination,
isEmpty: !response.data.data || response.data.data.length === 0,
};
},
getNextPageParam: (lastPage) => {
if (lastPage.pagination?.next_page_url) {
const currentPage = lastPage.pagination.page || 1;
return currentPage + 1;
}
return undefined;
},
enabled: options?.enabled !== false && !!collectionId,
initialPageParam: 1,
});
}

View File

@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query"
import { apiClient } from "@/lib/api"
import type { Carousel, Banner, PaginatedResponse } from "@/lib/types/api"
// Get all carousels
export function useCarousels(options?: { enabled?: boolean }) {
return useQuery({
queryKey: ["carousels"],
queryFn: async () => {
const response = await apiClient.get<PaginatedResponse<Carousel>>("/media/carousels")
return response.data.data || response.data
},
enabled: options?.enabled !== false,
staleTime: 1000 * 60 * 30, // 30 minutes
})
}
// Get all banners
export function useBanners(options?: { enabled?: boolean }) {
return useQuery({
queryKey: ["banners"],
queryFn: async () => {
const response = await apiClient.get<PaginatedResponse<Banner>>("/media/banners")
return response.data.data || response.data
},
enabled: options?.enabled !== false,
staleTime: 1000 * 60 * 30, // 30 minutes
})
}

View File

@@ -0,0 +1,47 @@
"use client"
import { useMutation } from "@tanstack/react-query"
import { apiClient } from "@/lib/api"
interface OpenStoreData {
firstName: string
lastName: string
email: string
phone: string
patentFile: File
}
interface OpenStoreResponse {
success: boolean
message: string
data?: any
}
const API_ENDPOINTS = {
openStore: "forms/newsletter-subscription",
}
export function useOpenStore() {
return useMutation<OpenStoreResponse, Error, OpenStoreData>({
mutationFn: async (data: OpenStoreData) => {
const formData = new FormData()
formData.append("firstname", data.firstName)
formData.append("lastname", data.lastName)
formData.append("email", data.email)
formData.append("phone", data.phone)
formData.append("file", data.patentFile)
const response = await apiClient.post<OpenStoreResponse>(
API_ENDPOINTS.openStore,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
}
)
return response.data
},
})
}

View File

@@ -0,0 +1,30 @@
import { Button } from "@/components/ui/button";
import { ShoppingCart } from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
export default function EmptyOrders() {
const t=useTranslations();
const router=useRouter();
return (
<div className="flex min-h-[60vh] items-center justify-center px-4">
<div className="w-full max-w-md rounded-2xl bg-linear-to-br from-blue-50 to-white p-8 text-center shadow-lg">
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-blue-100">
<ShoppingCart className="h-10 w-10 text-blue-600" />
</div>
<h2 className="mb-2 text-2xl font-semibold text-gray-900">
{t("orders_empty")}
</h2>
<p className="mb-6 text-sm text-gray-500">
{t("orders_empty_message")}
</p>
<Button onClick={()=>router.push("/")} className="w-full rounded-lg cursor-pointer bg-blue-600 px-6 py-3 text-sm font-medium text-white transition hover:bg-blue-700 active:scale-95">
{t("start_shopping")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,506 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
ChevronDown,
ChevronUp,
Package,
Calendar,
MapPin,
CreditCard,
ShoppingBag,
} from "lucide-react";
import { toast } from "sonner";
import { useOrders, useCancelOrder } from "@/lib/hooks";
import { useTranslations } from "next-intl";
import type { Order } from "@/lib/types/api";
import EmptyOrders from "./EmptyOrders";
import ErrorPage from "@/components/ErrorPage";
interface OrdersPageClientProps {
locale: string;
}
export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
const [orderToCancel, setOrderToCancel] = useState<Order | null>(null);
const [expandedOrders, setExpandedOrders] = useState<Set<number>>(new Set());
const t = useTranslations();
const { data: orders, isLoading, isError } = useOrders();
const { mutate: cancelOrder, isPending: isCancellingOrder } =
useCancelOrder();
const toggleOrderExpand = useCallback((orderId: number) => {
setExpandedOrders((prev) => {
const newSet = new Set(prev);
if (newSet.has(orderId)) {
newSet.delete(orderId);
} else {
newSet.add(orderId);
}
return newSet;
});
}, []);
const handleCancelOrder = useCallback((order: Order) => {
setOrderToCancel(order);
setIsCancelDialogOpen(true);
}, []);
const confirmCancelOrder = useCallback(() => {
if (!orderToCancel) return;
cancelOrder(orderToCancel.id, {
onSuccess: () => {
toast.success(t("order_cancelled"));
setIsCancelDialogOpen(false);
setOrderToCancel(null);
},
onError: (error: any) => {
toast.error(error.message || t("cancel_order_failed"));
},
});
}, [orderToCancel, cancelOrder, toast, t]);
const getStatusBadge = useCallback((status: string) => {
const lowerStatus = status.toLowerCase();
if (
lowerStatus.includes("ожидается") ||
lowerStatus.includes("pending") ||
lowerStatus.includes("garaşlama")
) {
return (
<Badge
variant="outline"
className="bg-yellow-50 text-yellow-700 border-yellow-300"
>
{status}
</Badge>
);
}
if (
lowerStatus.includes("обработка") ||
lowerStatus.includes("processing") ||
lowerStatus.includes("işlenýär")
) {
return (
<Badge variant="secondary" className="bg-blue-50 text-blue-700">
{status}
</Badge>
);
}
if (
lowerStatus.includes("отправлен") ||
lowerStatus.includes("shipped") ||
lowerStatus.includes("iberildi")
) {
return <Badge className="bg-purple-500">{status}</Badge>;
}
if (
lowerStatus.includes("доставлен") ||
lowerStatus.includes("delivered") ||
lowerStatus.includes("eltildi")
) {
return <Badge className="bg-green-600">{status}</Badge>;
}
if (
lowerStatus.includes("отменен") ||
lowerStatus.includes("cancelled") ||
lowerStatus.includes("ýatyryldy")
) {
return <Badge variant="destructive">{status}</Badge>;
}
return <Badge>{status}</Badge>;
}, []);
const isActiveOrder = useCallback((status: string) => {
const lower = status.toLowerCase();
return (
lower.includes("ожидается") ||
lower.includes("обработка") ||
lower.includes("отправлен") ||
lower.includes("pending") ||
lower.includes("processing") ||
lower.includes("shipped") ||
lower.includes("garaşylýar") ||
lower.includes("işlenýär") ||
lower.includes("iberildi")
);
}, []);
const activeOrders = useMemo(
() => orders?.filter((o) => isActiveOrder(o.status)) || [],
[orders, isActiveOrder]
);
const completedOrders = useMemo(
() => orders?.filter((o) => !isActiveOrder(o.status)) || [],
[orders, isActiveOrder]
);
const calculateTotal = useCallback((order: Order) => {
return order.orderItems.reduce((sum, item) => {
return sum + parseFloat(item.unit_price_amount) * item.quantity;
}, 0);
}, []);
if (isLoading) {
return (
<div className="mx-auto p-2 lg:p-6 md:p-4 mb-16 min-h-screen">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold mb-6">
{t("my_orders")}
</h1>
{/* Tabs Skeleton */}
<div className="mb-4 md:mb-6">
<div className="flex gap-2 mb-4">
<Skeleton className="h-10 w-32 rounded-md" />
<Skeleton className="h-10 w-32 rounded-md" />
</div>
</div>
{/* Order Cards Skeleton */}
<div className="space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i} className="overflow-hidden py-2 md:py-4 lg:py-6">
<div className="p-2 md:p-4 mx-2 md:mx-4 rounded-lg">
<div className="flex items-center justify-between">
{/* Left side - Order info */}
<div className="flex items-center gap-4 flex-1">
<Skeleton className="h-5 w-5 rounded" />
<div className="space-y-2">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-24" />
</div>
</div>
{/* Right side - Status and price */}
<div className="flex items-center gap-4">
<div className="flex flex-col md:flex-row gap-2 items-end">
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-6 w-24" />
</div>
<Skeleton className="h-5 w-5 rounded" />
</div>
</div>
</div>
</Card>
))}
</div>
</div>
);
}
if (isError) {
return <ErrorPage />;
}
if (isError || !orders || orders.length === 0) {
return <EmptyOrders />;
}
return (
<div className=" mx-auto p-2 lg:p-6 md:p-4 mb-16 min-h-screen">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold mb-6">
{t("my_orders")}
</h1>
<Tabs defaultValue="active" className="w-full">
<TabsList className="mb-4 md:mb-6 w-full md:w-fit gap-2 p-0">
<TabsTrigger value="active">
{t("active_orders")} ({activeOrders.length})
</TabsTrigger>
<TabsTrigger value="completed">
{t("completed_orders")} ({completedOrders.length})
</TabsTrigger>
</TabsList>
<TabsContent value="active">
{activeOrders.length === 0 ? (
<div className="flex items-center justify-center min-h-[40vh]">
<p className="text-xl text-gray-400">{t("no_active_orders")}</p>
</div>
) : (
<div className="space-y-4">
{activeOrders.map((order) => (
<CompactOrderCard
key={order.id}
order={order}
isExpanded={expandedOrders.has(order.id)}
onToggle={() => toggleOrderExpand(order.id)}
onCancel={handleCancelOrder}
isCancelling={isCancellingOrder}
getStatusBadge={getStatusBadge}
calculateTotal={calculateTotal}
showCancelButton
t={t}
/>
))}
</div>
)}
</TabsContent>
<TabsContent value="completed">
{completedOrders.length === 0 ? (
<div className="flex items-center justify-center min-h-[40vh]">
<p className="text-xl text-gray-400">
{t("no_completed_orders")}
</p>
</div>
) : (
<div className="space-y-4">
{completedOrders.map((order) => (
<CompactOrderCard
key={order.id}
order={order}
isExpanded={expandedOrders.has(order.id)}
onToggle={() => toggleOrderExpand(order.id)}
onCancel={handleCancelOrder}
isCancelling={isCancellingOrder}
getStatusBadge={getStatusBadge}
calculateTotal={calculateTotal}
showCancelButton={false}
t={t}
/>
))}
</div>
)}
</TabsContent>
</Tabs>
<Dialog open={isCancelDialogOpen} onOpenChange={setIsCancelDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("cancel_order")} #{orderToCancel?.id}
</DialogTitle>
<DialogDescription>{t("cancel_confirmation")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsCancelDialogOpen(false)}
disabled={isCancellingOrder}
className="cursor-pointer"
>
{t("keep_order")}
</Button>
<Button
variant="destructive"
onClick={confirmCancelOrder}
disabled={isCancellingOrder}
className="cursor-pointer"
>
{isCancellingOrder ? t("cancelling") : t("cancel_order")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
interface CompactOrderCardProps {
order: Order;
isExpanded: boolean;
onToggle: () => void;
onCancel: (order: Order) => void;
isCancelling: boolean;
getStatusBadge: (status: string) => React.ReactNode;
calculateTotal: (order: Order) => number;
showCancelButton: boolean;
t: any;
}
function CompactOrderCard({
order,
isExpanded,
onToggle,
onCancel,
isCancelling,
getStatusBadge,
calculateTotal,
showCancelButton,
t,
}: CompactOrderCardProps) {
const total = useMemo(() => calculateTotal(order), [calculateTotal, order]);
const itemCount = order.orderItems.length;
return (
<Card className="overflow-hidden transition-all py-2 md:py-4 lg:py-6 hover:shadow-md">
{/* Compact Header - Always Visible */}
<div
className="p-2 md:p-4 mx-2 md:mx-4 rounded-lg cursor-pointer bg-linear-to-r from-white to-gray-50 hover:from-gray-50 hover:to-gray-100 transition-colors"
onClick={onToggle}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-1">
<div className="flex items-center gap-2">
<Package className="h-5 w-5 text-gray-500" />
<div>
<h3 className="font-semibold text-base lg:text-lg">
{t("order_number")} {order.id}
</h3>
<p className="text-sm text-gray-500">
{itemCount} {itemCount === 1 ? t("product") : t("products")}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex flex-col md:flex-row gap-2 items-end">
{getStatusBadge(order.status)}
<div className="text-right">
<p className="font-bold text-lg text-green-600">
{total.toFixed(2)} TMT
</p>
</div>
</div>
{isExpanded ? (
<ChevronUp className="h-5 w-5 text-gray-400" />
) : (
<ChevronDown className="h-5 w-5 text-gray-400" />
)}
</div>
</div>
</div>
{/* Expandable Details */}
{isExpanded && (
<div className="border-t bg-white">
{/* Order Info Grid */}
<div className="p-4 grid grid-cols-1 md:grid-cols-2 gap-4 bg-gray-50">
{/* <div className="flex items-start gap-3">
<Calendar className="h-5 w-5 text-blue-500 mt-0.5" />
<div>
<p className="text-sm font-medium text-gray-700">
{t("delivery_date")}
</p>
<p className="text-sm text-gray-900">
{new Date(order.delivery_at).toLocaleDateString()} •{" "}
{order.delivery_time}
</p>
</div>
</div> */}
<div className="flex items-start gap-3">
<MapPin className="h-5 w-5 text-red-500 mt-0.5" />
<div>
<p className="text-sm font-medium text-gray-700">
{t("address")}
</p>
<p className="text-sm text-gray-900">
{order.customer_address}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<CreditCard className="h-5 w-5 text-green-500 mt-0.5" />
<div>
<p className="text-sm font-medium text-gray-700">
{t("payment_method")}
</p>
<p className="text-sm text-gray-900">{order.payment_type}</p>
</div>
</div>
<div className="flex items-start gap-3">
<ShoppingBag className="h-5 w-5 text-purple-500 mt-0.5" />
<div>
<p className="text-sm font-medium text-gray-700">
{t("shipping_method")}
</p>
<p className="text-sm text-gray-900">{order.shipping_method}</p>
</div>
</div>
</div>
{/* Products List */}
<div className="p-4">
<h4 className="font-semibold mb-3 text-gray-700">
{t("products")}:
</h4>
<div className="space-y-3 max-h-96 overflow-y-auto">
{order.orderItems.map((item, index) => (
<div
key={index}
className="flex items-center gap-4 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
>
<div className="relative w-16 h-16 shrink-0 rounded-md overflow-hidden bg-white border">
<Image
src={
item.product.images_400x400 || item.product.thumbnail
}
alt={item.product.name}
fill
className="object-contain p-1"
/>
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm line-clamp-2">
{item.product.name}
</p>
<p className="text-xs text-gray-500 mt-1">
{item.quantity} × {item.unit_price_amount} TMT
</p>
</div>
<div className="text-right">
<p className="font-semibold text-sm">
{(
parseFloat(item.unit_price_amount) * item.quantity
).toFixed(2)}{" "}
TMT
</p>
</div>
</div>
))}
</div>
</div>
{/* Footer with Total and Actions */}
<div className="border-t p-4 bg-gray-50">
<div className="flex items-center justify-between mb-3">
<span className="text-base font-semibold text-gray-700">
{t("total_price")}:
</span>
<span className="text-xl font-bold text-green-600">
{total.toFixed(2)} TMT
</span>
</div>
{showCancelButton && (
<Button
variant="destructive"
onClick={(e) => {
e.stopPropagation();
onCancel(order);
}}
disabled={isCancelling}
className="w-full cursor-pointer"
>
{t("cancel_order")}
</Button>
)}
</div>
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,77 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import type { Order, OrdersResponse } from "@/lib/types/api";
export function useOrders(options?: { page?: number; perPage?: number }) {
return useQuery<Order[]>({
queryKey: ["orders", options?.page],
queryFn: async () => {
const response = await apiClient.get<OrdersResponse>("/orders", {
params: {
page: options?.page || 1,
per_page: options?.perPage || 20,
},
});
return response.data.data;
},
staleTime: 1000 * 60 * 5,
retry: 1,
});
}
export function useOrder(id: number | string) {
return useQuery<Order | null>({
queryKey: ["order", id],
queryFn: async () => {
const response = await apiClient.get(`/orders/${id}`);
return response.data.data || null;
},
enabled: !!id,
staleTime: 1000 * 60 * 5,
retry: 1,
});
}
// export function useCreateOrder() {
// const queryClient = useQueryClient();
// return useMutation({
// mutationFn: async (orderData: CreateOrderRequest) => {
// const formData = new URLSearchParams();
// Object.entries(orderData).forEach(([key, value]) => {
// if (value !== null && value !== undefined) {
// formData.append(key, String(value));
// }
// });
// const response = await apiClient.post("/orders", formData, {
// headers: {
// "Content-Type": "application/x-www-form-urlencoded",
// },
// });
// return response.data;
// },
// onSuccess: () => {
// queryClient.invalidateQueries({ queryKey: ["orders"] });
// queryClient.invalidateQueries({ queryKey: ["cart"] });
// },
// });
// }
export function useCancelOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (orderId: number) => {
const response = await apiClient.delete(`/orders/${orderId}`);
return response.data;
},
onSuccess: (_, orderId) => {
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["order", orderId] });
},
});
}

View File

@@ -0,0 +1,90 @@
import { useState, useEffect, useRef, useCallback } from "react";
import Image from "next/image";
interface ProductImageGalleryProps {
images: string[];
productName: string;
noImageText: string;
}
export function ProductImageGallery({
images,
productName,
noImageText,
}: ProductImageGalleryProps) {
const [selectedImage, setSelectedImage] = useState(0);
const autoplayTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
useEffect(() => {
if (images.length <= 1) return;
const startAutoplay = () => {
autoplayTimerRef.current = setInterval(() => {
setSelectedImage((prev) => (prev + 1) % images.length);
}, 3000);
};
startAutoplay();
return () => {
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
};
}, [images.length]);
const handleImageSelect = useCallback(
(index: number) => {
setSelectedImage(index);
if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current);
if (images.length > 1) {
autoplayTimerRef.current = setInterval(() => {
setSelectedImage((prev) => (prev + 1) % images.length);
}, 3000);
}
},
[images.length]
);
return (
<div className="contents max-w-2xl">
<div className="relative">
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-white">
{images.length > 0 ? (
<Image
src={images[selectedImage]}
alt={productName}
fill
className="object-contain"
priority
/>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
{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>
);
}

View File

@@ -0,0 +1,126 @@
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Star } from "lucide-react";
interface ProductProperty {
name: string;
value: string;
}
interface ProductInfoCardProps {
name: string;
brandName?: string;
stock?: number;
barcode?: string;
colour?: string;
properties?: ProductProperty[];
description?: string;
averageRating: number;
reviewsCount: number;
t: (key: string, params?: any) => string;
}
export function ProductInfoCard({
name,
brandName,
stock,
barcode,
colour,
properties,
description,
averageRating,
reviewsCount,
t,
}: ProductInfoCardProps) {
return (
<div className="flex-1 space-y-6 bg-white">
<Card className="p-4 rounded-xl border-gray-200">
<h3 className="text-xl font-semibold mb-4">{name}</h3>
<div className="space-y-3">
{brandName && (
<>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{t("brands")}</span>
<span className="font-medium">{brandName}</span>
</div>
<Separator />
</>
)}
{stock !== undefined && (
<>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{t("stock")}</span>
<span
className={`font-medium ${
stock === 0
? "text-red-500"
: stock <= 5
? "text-orange-600"
: "text-green-600"
}`}
>
{stock === 0
? t("out_of_stock")
: stock <= 5
? `${t("only_left", { count: stock })}`
: stock}
</span>
</div>
<Separator />
</>
)}
{barcode && (
<>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{t("barcode")}</span>
<span className="font-mono text-sm">{barcode}</span>
</div>
<Separator />
</>
)}
{colour && (
<>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{t("color")}</span>
<span className="font-medium">{colour}</span>
</div>
<Separator />
</>
)}
{properties && properties.length > 0 && (
<>
{properties.map(
(prop, idx) =>
prop.value && (
<div key={idx}>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{prop.name}</span>
<span className="font-medium">{prop.value}</span>
</div>
{idx < properties.length - 1 && <Separator />}
</div>
)
)}
</>
)}
</div>
</Card>
{description && (
<Card className="p-4 rounded-xl border-gray-200 gap-2">
<h3 className="text-xl font-semibold mb-3">
{t("product_description")}
</h3>
<div
className="text-gray-700 leading-relaxed prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: description }}
/>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,553 @@
"use client";
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import {
useProductsBySlug,
useRelatedProducts,
useSubmitReview,
} from "@/features/products/hooks/useProducts";
import {
useAddToCart,
useUpdateCartItemQuantity,
useRemoveFromCart,
useCart,
cartEvents,
} from "@/features/cart/hooks/useCart";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { ProductImageGallery } from "./ProductImageGallery";
import { ProductInfoCard } from "./ProductInfoCard";
import { ProductPurchaseCard } from "./ProductPurchaseCard";
import { ProductReviewsSection } from "./ProductReviewsSection";
import { RelatedProductsSection } from "./RelatedProductsSection";
import { ReviewModal } from "./ReviewModal";
import { StockLimitModal } from "./StockLimitModal";
import {
useIsFavorite,
useToggleFavorite,
} from "@/features/favorites/hooks/useFavorites";
interface ProductDetailProps {
slug: string;
}
const PENDING_PRODUCT_UPDATES_KEY = "pendingProductUpdates";
interface PendingUpdate {
quantity: number;
timestamp: number;
retryCount: number;
}
// const DEBUG = true
// const log = (...args: any[]) => {
// if (DEBUG) console.log("[ProductPage]", ...args)
// }
export default function ProductPageContent({ slug }: ProductDetailProps) {
const [localQuantity, setLocalQuantity] = useState(1);
const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState(false);
const [showStockModal, setShowStockModal] = useState(false);
const [showReviewModal, setShowReviewModal] = useState(false);
const [isInitialized, setIsInitialized] = useState(false);
const t = useTranslations();
const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isRequestInFlightRef = useRef(false);
const pendingQuantityRef = useRef<number | null>(null);
const retryCountRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const syncToServerRef = useRef<((quantity: number) => void) | null>(null);
const retrySyncRef = useRef<((quantity: number) => void) | null>(null);
const shouldSyncFromCartRef = useRef(true);
const lastSyncedQuantityRef = useRef<number | null>(null);
const {
data: product,
isLoading: productLoading,
error,
refetch: refetchProduct,
} = useProductsBySlug(slug);
const { 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: relatedProducts } = useRelatedProducts(product?.id || 0, {
enabled: !!product?.id,
});
const addToCartMutation = useAddToCart();
const updateCartMutation = useUpdateCartItemQuantity();
const removeFromCartMutation = useRemoveFromCart();
const submitReviewMutation = useSubmitReview();
const cartItem = useMemo(() => {
const item = cartData?.data?.find(
(item: any) => item.product?.id === product?.id
);
return item;
}, [cartData, product, isInitialized]);
const isInCart = !!cartItem;
const availableStock = product?.stock || 0;
const imageUrls = useMemo(
() =>
product?.media?.map(
(m) => m.images_800x800 || m.images_720x720 || m.thumbnail
) || [],
[product]
);
const reviews = useMemo(() => product?.reviews_resources || [], [product]);
const averageRating = useMemo(
() =>
product?.reviews?.rating ? Number.parseFloat(product.reviews.rating) : 0,
[product]
);
const transformedRelatedProducts = useMemo(() => {
if (!relatedProducts) return [];
return relatedProducts.map((p) => ({
id: p.id,
slug: p.slug,
name: p.name,
price_amount: p.price_amount,
old_price_amount: p.old_price_amount ?? undefined,
struct_price_text: `${p.price_amount} TMT`,
discount: null,
discount_text: null,
stock: p.stock,
media: p.media,
labels: [],
price_color: undefined,
}));
}, [relatedProducts]);
useEffect(() => {
if (!product?.id || isInitialized) return;
if (cartItem?.product_quantity) {
const serverQuantity = cartItem.product_quantity;
setLocalQuantity(serverQuantity);
lastSyncedQuantityRef.current = serverQuantity;
}
setIsInitialized(true);
}, [product?.id, cartItem, isInitialized]);
useEffect(() => {
setLocalQuantity(cartItem?.product_quantity || 1);
}, [cartItem]);
const savePendingUpdate = useCallback(
(quantity: number) => {
if (!product?.id) return;
try {
const stored = sessionStorage.getItem(PENDING_PRODUCT_UPDATES_KEY);
const pending: Record<number, PendingUpdate> = stored
? JSON.parse(stored)
: {};
pending[product.id] = {
quantity,
timestamp: Date.now(),
retryCount: retryCountRef.current,
};
sessionStorage.setItem(
PENDING_PRODUCT_UPDATES_KEY,
JSON.stringify(pending)
);
} catch (error) {
console.error("Failed to save pending update:", error);
}
},
[product?.id]
);
const clearPendingUpdate = useCallback(() => {
if (!product?.id) return;
try {
const stored = sessionStorage.getItem(PENDING_PRODUCT_UPDATES_KEY);
if (stored) {
const pending: Record<number, PendingUpdate> = JSON.parse(stored);
delete pending[product.id];
if (Object.keys(pending).length === 0) {
sessionStorage.removeItem(PENDING_PRODUCT_UPDATES_KEY);
} else {
sessionStorage.setItem(
PENDING_PRODUCT_UPDATES_KEY,
JSON.stringify(pending)
);
}
}
} catch (error) {
console.error("Failed to clear pending update:", error);
}
}, [product?.id]);
const retrySync = useCallback(
(quantity: number) => {
const maxRetries = 4;
const retryCount = retryCountRef.current;
if (retryCount >= maxRetries) {
setSyncError(true);
setIsSyncing(false);
shouldSyncFromCartRef.current = true;
toast.error(t("error"), {
description: t("update_quantity_failed"),
});
return;
}
const delay = Math.min(1000 * Math.pow(2, retryCount), 16000);
retryCountRef.current++;
retryTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(quantity);
}, delay);
},
[t]
);
retrySyncRef.current = retrySync;
const syncToServer = useCallback(
async (quantity: number) => {
if (!product?.id) return;
if (isRequestInFlightRef.current) {
pendingQuantityRef.current = quantity;
return;
}
isRequestInFlightRef.current = true;
setIsSyncing(true);
setSyncError(false);
try {
if (quantity === 0) {
await removeFromCartMutation.mutateAsync(product.id);
toast.success(t("removed_from_cart"));
} else if (isInCart) {
await updateCartMutation.mutateAsync({
productId: product.id,
quantity: quantity,
});
} else {
await addToCartMutation.mutateAsync({
productId: product.id,
quantity: quantity,
});
}
retryCountRef.current = 0;
clearPendingUpdate();
if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current;
pendingQuantityRef.current = null;
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100);
}
} catch (error) {
setLocalQuantity(cartItem?.product_quantity || 1);
toast.error(t("failed_to_update_quantity"), {
description: "Please try again",
});
retrySyncRef.current?.(quantity);
} finally {
isRequestInFlightRef.current = false;
setIsSyncing(false);
}
},
[
product?.id,
isInCart,
updateCartMutation,
addToCartMutation,
removeFromCartMutation,
cartItem,
clearPendingUpdate,
t,
]
);
syncToServerRef.current = syncToServer;
useEffect(() => {
if (!isInCart || !product?.id) return;
if (localQuantity === (cartItem?.product_quantity || 1)) {
return;
}
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(localQuantity);
}, 800);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [localQuantity, isInCart, product?.id, cartItem?.product_quantity]);
useEffect(() => {
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
};
}, []);
const handleAddToCart = useCallback(async () => {
if (!product?.id) return;
if (localQuantity > availableStock) {
setShowStockModal(true);
return;
}
setIsSyncing(true);
shouldSyncFromCartRef.current = false;
try {
await addToCartMutation.mutateAsync({
productId: product.id,
quantity: localQuantity,
});
lastSyncedQuantityRef.current = localQuantity;
setTimeout(() => {
shouldSyncFromCartRef.current = true;
}, 150);
setIsSyncing(false);
toast.success(t("added_to_cart"), {
description: `${product.name} ${t("added_to_cart_description")}`,
});
} catch (error) {
console.error("Add to cart error:", error);
setIsSyncing(false);
shouldSyncFromCartRef.current = true;
toast.error(t("error"), {
description: t("add_to_cart_failed"),
});
}
}, [product, localQuantity, availableStock, addToCartMutation, t]);
const handleQuantityIncrease = useCallback(() => {
if (localQuantity >= availableStock) {
setShowStockModal(true);
return;
}
setLocalQuantity((prev) => {
const newVal = prev + 1;
return newVal;
});
}, [localQuantity, availableStock]);
const handleQuantityDecrease = useCallback(() => {
if (localQuantity <= 0) return;
setLocalQuantity((prev) => {
const newVal = prev - 1;
return newVal;
});
}, [localQuantity]);
const handleToggleFavorite = useCallback(
(e?: React.MouseEvent<HTMLButtonElement>) => {
e?.preventDefault();
e?.stopPropagation();
if (!product?.id) {
toast.error(t("error"), {
description: "Product ID not found",
});
return;
}
toggleFavoriteMutation(
{
productId: product.id,
isFavorite,
},
{
onSuccess: (data) => {
toast.success(
data?.wasAdded
? t("added_to_favorites")
: t("removed_from_favorites")
);
},
onError: () => {
toast.error(t("error"), {
description: "Try again later",
});
},
}
);
},
[product?.id, isFavorite, toggleFavoriteMutation, t]
);
const handleSubmitReview = useCallback(
async (rating: number, text: string) => {
if (!product?.id || rating === 0 || !text.trim()) {
toast.error(t("error"), {
description: "Please provide rating and review text",
});
return;
}
try {
await submitReviewMutation.mutateAsync({
productId: product.id,
rating: rating,
title: text,
source: "site",
});
await refetchProduct();
toast.success("Review submitted successfully!");
setShowReviewModal(false);
} catch (error) {
toast.error(t("error"), {
description: "Failed to submit review",
});
}
},
[product?.id, submitReviewMutation, refetchProduct, t]
);
const loadingSkeleton = useMemo(
() => (
<div className=" mx-auto px-4 py-8">
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 max-w-2xl">
<Skeleton className="aspect-square w-full rounded-2xl" />
<div className="mt-4 flex gap-2">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="w-16 h-16 rounded" />
))}
</div>
</div>
<div className="flex-1 space-y-6">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-20 w-full" />
</div>
</div>
</div>
),
[]
);
if (productLoading) return loadingSkeleton;
if (error || !product) {
return (
<div className=" mx-auto px-4 py-8 text-center">
<h2 className="text-2xl font-bold text-red-600">
{t("product_not_found")}
</h2>
<p className="text-gray-500 mt-2">
{t("product_not_found_description")}
</p>
</div>
);
}
return (
<>
<div className="px-2 md:px-4 lg:px-6 rounded-lg mb-18 space-y-8 max-w-[1504px] mx-auto">
<div className="flex flex-col lg:flex-row gap-8 rounded-b-lg bg-white p-4">
<ProductImageGallery
images={imageUrls}
productName={product.name}
noImageText={t("no_image")}
/>
<ProductInfoCard
name={product.name}
brandName={product.brand?.name ?? undefined}
stock={product.stock}
barcode={product.barcode}
colour={product.colour ?? undefined}
properties={product.properties}
description={product.description}
averageRating={averageRating}
reviewsCount={product.reviews?.count || 0}
t={t}
/>
<ProductPurchaseCard
price={product.price_amount}
oldPrice={product.old_price_amount ?? undefined}
isInCart={isInCart}
localQuantity={localQuantity}
availableStock={availableStock}
isSyncing={isSyncing}
syncError={syncError}
isFavorite={isFavorite}
productStock={product.stock}
channelName={product.channel?.[0]?.name}
onAddToCart={handleAddToCart}
onQuantityIncrease={handleQuantityIncrease}
onQuantityDecrease={handleQuantityDecrease}
onToggleFavorite={handleToggleFavorite}
t={t}
/>
</div>
<ProductReviewsSection
reviews={reviews}
averageRating={averageRating}
isLoading={false}
onWriteReview={() => setShowReviewModal(true)}
/>
<RelatedProductsSection products={transformedRelatedProducts} />
</div>
<StockLimitModal
open={showStockModal}
onOpenChange={setShowStockModal}
productName={product.name}
availableStock={availableStock}
t={t}
/>
<ReviewModal
open={showReviewModal}
onOpenChange={setShowReviewModal}
onSubmit={handleSubmitReview}
isSubmitting={submitReviewMutation.isPending}
/>
</>
);
}

View File

@@ -0,0 +1,190 @@
import Link from "next/link";
import { Minus, Plus, Heart, ShoppingCart, Store } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
interface ProductPurchaseCardProps {
price: string;
oldPrice?: string;
isInCart: boolean;
localQuantity: number;
availableStock: number;
isSyncing: boolean;
syncError: boolean;
isFavorite: boolean;
productStock: number;
channelName?: string;
onAddToCart: () => void;
onQuantityIncrease: () => void;
onQuantityDecrease: () => void;
onToggleFavorite: () => void;
t: (key: string) => string;
}
export function ProductPurchaseCard({
price,
oldPrice,
isInCart,
localQuantity,
availableStock,
isSyncing,
syncError,
isFavorite,
productStock,
channelName,
onAddToCart,
onQuantityIncrease,
onQuantityDecrease,
onToggleFavorite,
t,
}: ProductPurchaseCardProps) {
return (
<div className="lg:w-[380px] space-y-4">
<Card className="p-6 rounded-xl">
<div className="flex justify-between items-start mb-6">
<span className="text-lg text-gray-500">{t("price")}:</span>
<div className="flex flex-col items-end">
<span className="text-3xl font-bold text-primary">{price} TMT</span>
{oldPrice && parseFloat(oldPrice) > 0 && (
<span className="text-lg text-gray-400 line-through">
{oldPrice} TMT
</span>
)}
</div>
</div>
<div className="space-y-2">
{isInCart ? (
<>
<Link href="/cart">
<Button
size="lg"
className="w-full rounded-lg cursor-pointer text-lg font-bold bg-green-600 hover:bg-green-700 mb-4"
>
<ShoppingCart className="mr-2 h-5 w-5" />
{t("go_to_cart")}
</Button>
</Link>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={onQuantityDecrease}
disabled={isSyncing}
className={`rounded-lg cursor-pointer h-12 w-12 ${
isSyncing ? "opacity-70" : ""
}`}
>
<Minus className="h-5 w-5" />
</Button>
<div className="flex-1 text-center font-semibold text-xl border rounded-xl h-12 flex items-center justify-center relative">
{localQuantity}
{syncError && (
<span
className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full"
title="Sync error"
/>
)}
</div>
<Button
variant="outline"
size="icon"
onClick={onQuantityIncrease}
disabled={isSyncing}
className={`rounded-lg cursor-pointer h-12 w-12 ${
isSyncing ? "opacity-70" : ""
}`}
>
<Plus className="h-5 w-5" />
</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 className="flex items-center gap-2">
<Button
size="lg"
onClick={onAddToCart}
disabled={isSyncing || productStock === 0}
className="flex-1 rounded-lg text-lg font-bold bg-[#005bff] hover:bg-[#0041c4] cursor-pointer"
>
{isSyncing ? (
<>{t("adding")}</>
) : (
<>
<ShoppingCart className="mr-2 h-5 w-5" />
{productStock === 0 ? t("out_of_stock") : t("add_to_cart")}
</>
)}
</Button>
<Button
variant="outline"
size="icon"
onClick={onToggleFavorite}
className={`rounded-lg h-12 w-12 transition-all border cursor-pointer ${
isFavorite
? "bg-[#F0F8FF] border-blue-300 hover:bg-blue-100"
: "hover:bg-gray-50"
}`}
>
<Heart
className={`h-6! w-6! transition-all ${
isFavorite
? "fill-[#005bff] text-[#005bff]"
: "text-[#005bff]"
}`}
/>
</Button>
</div>
)}
</div>
</Card>
{channelName && (
<Card className="p-6 rounded-xl">
<div className="flex items-center gap-4 mb-4">
<Avatar className="w-14 h-14 bg-primary/10">
<AvatarFallback className="bg-transparent">
<Store className="h-6 w-6 text-primary" />
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm text-gray-500">{t("store")}</p>
<h4 className="text-lg font-bold">{channelName}</h4>
</div>
</div>
<Button
variant="outline"
size="lg"
className="w-full cursor-pointer rounded-lg"
>
{t("write_to_store")}
</Button>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,94 @@
import { Star, Send } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "next-intl";
interface Review {
id: number;
rating: number;
title: string;
created_at: string;
}
interface ProductReviewsSectionProps {
reviews: Review[];
averageRating: number;
isLoading: boolean;
onWriteReview: () => void;
}
export function ProductReviewsSection({
reviews,
averageRating,
isLoading,
onWriteReview,
}: ProductReviewsSectionProps) {
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>
);
};
const t= useTranslations();
return (
<Card className="p-6 rounded-xl">
<div className="flex justify-between items-center ">
<div>
<h3 className="text-2xl font-bold">{t("customer_reviews")}</h3>
<div className="flex items-center gap-2 mt-2">
{renderStars(Math.round(averageRating))}
{/* <span className="text-sm text-gray-600">
{averageRating.toFixed(1)} out of 5
</span> */}
</div>
</div>
<Button onClick={onWriteReview} className="rounded-lg cursor-pointer bg-[#005bff] hover:bg-[#0041c4]">
<Send className="mr-2 h-4 w-4" />
{t("write_review")}
</Button>
</div>
<Separator className="my-4" />
{isLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-24 w-full" />
))}
</div>
) : reviews.length > 0 ? (
<div className="space-y-4">
{reviews.map((review) => (
<div key={review.id} className="border-b pb-4 last:border-0">
<div className="flex items-start justify-between mb-2">
<div>
{renderStars(review.rating)}
</div>
</div>
<p className="text-gray-700">{review.title}</p>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-500">
{t("no_reviews")}
</div>
)}
</Card>
);
}

View File

@@ -0,0 +1,74 @@
import ProductCard from "@/features/home/components/ProductCard";
import {useTranslations} from "next-intl";
interface RelatedProduct {
id: number;
slug: string;
name: string;
price_amount: string;
old_price_amount?: string;
struct_price_text: string;
discount?: number | null;
discount_text?: string | null;
stock?: number;
media: Array<{
images_800x800?: string;
images_720x720?: string;
images_400x400?: string;
thumbnail: string;
}>;
labels?: Array<{
text: string;
bg_color: string;
}>;
price_color?: string;
}
interface RelatedProductsSectionProps {
products: RelatedProduct[];
}
export function RelatedProductsSection({
products,
}: RelatedProductsSectionProps) {
const t = useTranslations();
if (!products || products.length === 0) return null;
return (
<div className="bg-white rounded-lg p-6">
<h2 className="text-2xl font-bold mb-6">{t("related_products")}</h2>
<div className="grid grid-cols-2 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{products.slice(0, 4).map((product) => {
const images =
product.media?.map(
(m) =>
m.images_800x800 ||
m.images_720x720 ||
m.images_400x400 ||
m.thumbnail
) || [];
return (
<ProductCard
key={product.id}
id={product.id}
name={product.name}
price={parseFloat(product.price_amount) || null}
struct_price_text={
product.struct_price_text || `${product.price_amount} TMT`
}
discount={product.discount}
discount_text={product.discount_text}
images={images}
labels={product.labels || []}
price_color={product.price_color}
height={360}
width={280}
button={true}
stock={product.stock}
/>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { useState } from "react";
import { Star, Send } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useTranslations } from "next-intl";
interface ReviewModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (rating: number, text: string) => Promise<void>;
isSubmitting: boolean;
}
export function ReviewModal({
open,
onOpenChange,
onSubmit,
isSubmitting,
}: ReviewModalProps) {
const [rating, setRating] = useState(0);
const [text, setText] = useState("");
const [hoveredStar, setHoveredStar] = useState(0);
const t = useTranslations();
const handleClose = () => {
onOpenChange(false);
setRating(0);
setText("");
setHoveredStar(0);
};
const handleSubmit = async () => {
await onSubmit(rating, text);
handleClose();
};
const renderStars = () => {
return (
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`h-5 w-5 cursor-pointer transition-all ${
star <= (hoveredStar || rating)
? "fill-yellow-400 text-yellow-400"
: "text-gray-300"
}`}
onClick={() => setRating(star)}
onMouseEnter={() => setHoveredStar(star)}
onMouseLeave={() => setHoveredStar(0)}
/>
))}
</div>
);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-xl">{t("write_review")}</DialogTitle>
<DialogDescription>
{t("share_experience")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 pt-4">
<div>
<label className="block text-sm font-medium mb-2">{t("rating")}</label>
{renderStars()}
</div>
<div>
<label className="block text-sm font-medium mb-2">
{t("your_review")}
</label>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={t("write_review")}
className="min-h-[120px] resize-none"
maxLength={500}
/>
<p className="text-xs text-gray-500 mt-1">
{text.length}/500 {t("characters")}
</p>
</div>
</div>
<div className="flex gap-3 mt-6">
<Button
variant="outline"
onClick={handleClose}
className="flex-1 rounded-lg cursor-pointer"
>
{t("cancel")}
</Button>
<Button
onClick={handleSubmit}
disabled={rating === 0 || !text.trim() || isSubmitting}
className="flex-1 rounded-lg cursor-pointer bg-[#005bff] hover:bg-[#0041c4]"
>
{isSubmitting ? (
<>
{t("submitting")}
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
{t("submit_review")}
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,56 @@
import { AlertTriangle } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface StockLimitModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
productName: string;
availableStock: number;
t: (key: string, params?: any) => string;
}
export function StockLimitModal({
open,
onOpenChange,
productName,
availableStock,
t,
}: StockLimitModalProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<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: productName,
stock: availableStock,
})}
</DialogDescription>
</DialogHeader>
<div className="flex justify-center mt-4">
<Button
onClick={() => onOpenChange(false)}
className="w-full rounded-lg cursor-pointer"
>
{t("understood")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,236 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import type { Review, Product, PaginatedResponse } from "@/lib/types/api";
// Types
interface PaginationOptions {
enabled?: boolean;
page?: number;
limit?: number;
}
interface ReviewSubmission {
productId: number | string;
rating: number;
title: string;
source?: string;
}
interface ReviewUpdate {
reviewId: number | string;
rating?: number;
title?: string;
source?: string;
}
// Constants
const DEFAULT_STALE_TIME = 1000 * 60 * 5; // 5 minutes
const EXTENDED_STALE_TIME = 1000 * 60 * 15; // 15 minutes
// Query Keys Factory
const reviewKeys = {
all: ["reviews"],
lists: () => [...reviewKeys.all, "list"],
list: (page?: number, limit?: number) => [...reviewKeys.lists(), page, limit],
details: () => [...reviewKeys.all, "detail"],
detail: (id: number | string) => [...reviewKeys.details(), id],
related: (id: number | string) => [...reviewKeys.detail(id), "related"],
byProduct: (productId: number | string, page?: number, limit?: number) => [
...reviewKeys.all,
"product",
productId,
page,
limit,
],
};
const productKeys = {
all: ["products"],
details: () => [...productKeys.all, "detail"],
detail: (id: number | string) => [...productKeys.details(), id],
bySlug: (slug: string) => [...productKeys.all, "slug", slug],
related: (id: number | string) => [...productKeys.detail(id), "related"],
};
// Generic fetch function
async function fetchData<T>(
url: string,
params?: Record<string, any>
): Promise<T> {
const response = await apiClient.get<T>(url, { params });
return response.data;
}
// Review Queries
export function useReview(
reviewId: number | string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: reviewKeys.detail(reviewId),
queryFn: () => fetchData<Review>(`/reviews/${reviewId}`),
enabled: options?.enabled !== false && !!reviewId,
staleTime: DEFAULT_STALE_TIME * 2,
});
}
export function useReviews(options?: PaginationOptions) {
return useQuery({
queryKey: reviewKeys.list(options?.page, options?.limit),
queryFn: async () => {
const response = await fetchData<PaginatedResponse<Review>>("/reviews", {
page: options?.page || 1,
limit: options?.limit,
});
return {
data: response.data || [],
pagination: response.pagination || {},
};
},
enabled: options?.enabled !== false,
staleTime: DEFAULT_STALE_TIME,
});
}
export function useRelatedReviews(
reviewId: number | string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: reviewKeys.related(reviewId),
queryFn: async () => {
const response = await fetchData<PaginatedResponse<Review>>(
`/reviews/${reviewId}/related`
);
return response.data || response;
},
enabled: options?.enabled !== false && !!reviewId,
staleTime: EXTENDED_STALE_TIME,
});
}
export function useProductReviews(
productId: number | string,
options?: PaginationOptions
) {
return useQuery({
queryKey: reviewKeys.byProduct(productId, options?.page, options?.limit),
queryFn: async () => {
const response = await fetchData<PaginatedResponse<Review>>(
`/products/${productId}/reviews`,
{
page: options?.page || 1,
limit: options?.limit || 10,
}
);
return {
data: response.data || [],
pagination: response.pagination || {},
};
},
enabled: options?.enabled !== false && !!productId,
staleTime: DEFAULT_STALE_TIME,
});
}
// Product Queries
export function useProduct(
productId: number | string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: productKeys.detail(productId),
queryFn: () => fetchData<Product>(`/products/${productId}`),
enabled: options?.enabled !== false && !!productId,
staleTime: DEFAULT_STALE_TIME * 2,
});
}
export function useProductsBySlug(
slug: string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: productKeys.bySlug(slug),
queryFn: async () => {
const response = await fetchData<{ data: Product }>(`/products/${slug}`);
return response.data || response;
},
enabled: options?.enabled !== false && !!slug,
staleTime: DEFAULT_STALE_TIME * 2,
});
}
export function useRelatedProducts(
productId: number | string,
options?: { enabled?: boolean }
) {
return useQuery({
queryKey: productKeys.related(productId),
queryFn: async () => {
const response = await fetchData<PaginatedResponse<Product>>(
`/products/${productId}/related`
);
return response.data || [];
},
enabled: options?.enabled !== false && !!productId,
staleTime: EXTENDED_STALE_TIME,
});
}
// Review Mutations
function useReviewMutation<TVariables, TData = any>(
mutationFn: (variables: TVariables) => Promise<TData>,
invalidateKeys: (variables: TVariables, data?: TData) => any[]
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn,
onSuccess: (data, variables) => {
const keys = invalidateKeys(variables, data);
keys.forEach((key) => {
queryClient.invalidateQueries({ queryKey: key });
});
},
});
}
export function useSubmitReview() {
return useReviewMutation<ReviewSubmission>(
async ({ productId, rating, title, source = "site" }) => {
const response = await apiClient.post<{
message: string;
data: Review[];
}>(`/products/${productId}/reviews`, { rating, title, source });
return response.data;
},
(variables) => [
reviewKeys.byProduct(variables.productId),
productKeys.bySlug(""),
reviewKeys.all,
]
);
}
export function useUpdateReview() {
return useReviewMutation<ReviewUpdate>(
async ({ reviewId, rating, title, source }) => {
const response = await apiClient.put<Review>(
`/reviews/${reviewId}`,
{ rating, title, source },
{ headers: { "Content-Type": "application/json" } }
);
return response.data;
},
(variables) => [reviewKeys.detail(variables.reviewId), reviewKeys.all]
);
}
export function useDeleteReview() {
return useReviewMutation<number | string>(
(reviewId) =>
apiClient.delete(`/reviews/${reviewId}`).then((res) => res.data),
(reviewId) => [reviewKeys.detail(reviewId), reviewKeys.all]
);
}

View File

@@ -0,0 +1,368 @@
"use client";
import { useCallback, useMemo, useState, useEffect } from "react";
import { LogOut, Edit2, Save, X, User, Phone, MapPin } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useUserProfile, useUpdateProfile } from "@/lib/hooks";
import { useLogout } from "@/lib/hooks/useAuth";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
interface ProfilePageProps {
params: Promise<{ locale: string }>;
}
export default function ClientProfilePage(props: ProfilePageProps) {
const { data: user, isLoading, error } = useUserProfile();
const updateProfile = useUpdateProfile();
const t = useTranslations();
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({
name: "",
last_name: "",
phone_number: "",
address: "",
});
useEffect(() => {
if (user && !isEditing) {
setFormData({
name: user.first_name || "",
last_name: user.last_name || "",
phone_number: user.phone_number || "",
address: user.address || "",
});
}
}, [user, isEditing]);
const { mutate: logout, isPending: isLoggingOut } = useLogout();
const handleLogout = useCallback(() => {
logout();
window.location.href = "/";
}, []);
const handleEdit = useCallback(() => {
if (user) {
setFormData({
name: user.first_name || "",
last_name: user.last_name || "",
phone_number: user.phone_number || "",
address: user.address || "",
});
setIsEditing(true);
}
}, [user]);
const handleCancel = useCallback(() => {
setIsEditing(false);
if (user) {
setFormData({
name: user.first_name || "",
last_name: user.last_name || "",
phone_number: user.phone_number || "",
address: user.address || "",
});
}
}, [user]);
const handleSave = useCallback(async () => {
if (!formData.name.trim()) {
toast.error(t("requiredField") || "Name is required");
return;
}
const apiData = {
name: formData.name.trim(),
last_name: formData.last_name.trim(),
phone_number: formData.phone_number.trim(),
address: formData.address.trim(),
};
try {
await updateProfile.mutateAsync(apiData);
setIsEditing(false);
toast.success(
t("profile_updated_success") || "Profile updated successfully"
);
} catch (err: any) {
const errorMessage =
err?.response?.data?.message ||
t("profile_update_error") ||
"Failed to update profile";
toast.error(errorMessage);
console.error("[Profile] Update error:", err);
}
}, [formData, updateProfile, t]);
const handleInputChange = useCallback(
(field: keyof typeof formData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
},
[]
);
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=" mx-auto max-w-4xl">
<div className="mb-6 sm:mb-8">
<Skeleton className="h-8 sm:h-10 w-32 sm:w-40 mb-2" />
<Skeleton className="h-4 w-48 sm:w-64" />
</div>
<Card className="shadow-sm">
<CardHeader>
<Skeleton className="h-6 w-32 mb-2" />
<Skeleton className="h-4 w-48" />
</CardHeader>
<CardContent className="space-y-4 sm:space-y-6">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-10 sm:h-11 w-full" />
</div>
))}
</CardContent>
</Card>
</div>
</div>
),
[]
);
if (isLoading) {
return loadingSkeleton;
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<Card className="w-full max-w-md shadow-sm">
<CardContent className="pt-6 text-center">
<div className="w-12 h-12 sm:w-14 sm:h-14 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-4">
<X className="h-6 w-6 sm:h-7 sm:w-7 text-red-600" />
</div>
<p className="text-red-600 mb-4 text-sm sm:text-base">
{t("error_loading_profile")}
</p>
<Button
onClick={() => window.location.reload()}
className="w-full sm:w-auto cursor-pointer"
>
{t("try_again")}
</Button>
</CardContent>
</Card>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 p-4 sm:p-6 lg:p-8 pb-20 sm:pb-24">
<div className=" mx-auto max-w-4xl">
{/* Header Section */}
<div className="mb-6 sm:mb-8">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold text-gray-900 mb-1 sm:mb-2 truncate">
{t("profile")}
</h1>
<p className="text-sm sm:text-base text-gray-600">
{isEditing
? t("edit_your_information")
: t("view_your_information")}
</p>
</div>
<div className="shrink-0 w-12 h-12 sm:w-14 sm:h-14 bg-blue-600 rounded-full flex items-center justify-center shadow-sm">
<User className="h-6 w-6 sm:h-7 sm:w-7 text-white" />
</div>
</div>
</div>
{/* Profile Card */}
<Card className="shadow-sm border border-gray-200 mb-4 sm:mb-6">
<CardHeader className="border-b border-gray-100 pb-4 sm:pb-5">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<CardTitle className="text-lg sm:text-xl text-gray-900">
{t("personal_info")}
</CardTitle>
<CardDescription className="text-xs sm:text-sm text-gray-600 mt-1">
{t("profile_description")}
</CardDescription>
</div>
{!isEditing && (
<Button
onClick={handleEdit}
variant="outline"
size="sm"
className="self-start sm:self-center cursor-pointer border-gray-300 hover:bg-gray-50 text-gray-700 h-9"
>
<Edit2 className="h-3.5 w-3.5 sm:h-4 sm:w-4 mr-1.5 sm:mr-2" />
<span className="text-sm">{t("edit")}</span>
</Button>
)}
</div>
</CardHeader>
<CardContent className="pt-5 sm:pt-6 space-y-4 sm:space-y-5">
{user && (
<>
{/* Name Fields - Grid on larger screens */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-5">
<div className="space-y-2">
<Label
htmlFor="name"
className="text-sm font-medium text-gray-700 flex items-center gap-1.5"
>
<User className="h-3.5 w-3.5 text-gray-400" />
{t("first_name")}
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) =>
handleInputChange("name", 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_first_name")}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="lastName"
className="text-sm font-medium text-gray-700 flex items-center gap-1.5"
>
<User className="h-3.5 w-3.5 text-gray-400" />
{t("last_name")}
</Label>
<Input
id="lastName"
value={formData.last_name}
onChange={(e) =>
handleInputChange("last_name", 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_last_name")}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 sm:gap-5">
{/* Phone Field */}
<div className="space-y-2">
<Label
htmlFor="phone"
className="text-sm font-medium text-gray-700 flex items-center gap-1.5"
>
<Phone className="h-3.5 w-3.5 text-gray-400" />
{t("phone_number")}
</Label>
<Input
id="phone"
value={formData.phone_number}
onChange={(e) =>
handleInputChange("phone_number", e.target.value)
}
disabled={!isEditing}
className={`h-10 sm:h-11 text-sm sm:text-base ${
isEditing
? "border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
: "bg-gray-50 border-gray-200 text-gray-700"
}`}
placeholder={t("enter_phone_number")}
/>
</div>
{/* Address Field */}
<div className="space-y-2">
<Label
htmlFor="address"
className="text-sm font-medium text-gray-700 flex items-center gap-1.5"
>
<MapPin className="h-3.5 w-3.5 text-gray-400" />
{t("address")}
</Label>
<Input
id="address"
value={formData.address}
onChange={(e) =>
handleInputChange("address", e.target.value)
}
disabled={!isEditing}
className={`h-10 sm:h-11 text-sm sm:text-base ${
isEditing
? "border-gray-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white"
: "bg-gray-50 border-gray-200 text-gray-700"
}`}
placeholder={t("enter_address")}
/>
</div>
</div>
{/* Action Buttons - Edit Mode */}
{isEditing && (
<div className="flex flex-col sm:flex-row gap-3 pt-4 sm:pt-5 border-t border-gray-100">
<Button
onClick={handleSave}
disabled={updateProfile.isPending}
className="w-full sm:flex-1 cursor-pointer bg-blue-600 hover:bg-blue-700 h-10 sm:h-11 text-sm sm:text-base font-medium shadow-sm"
>
<Save className="h-4 w-4 mr-2" />
{updateProfile.isPending
? t("saving")
: t("save_changes")}
</Button>
<Button
onClick={handleCancel}
variant="outline"
disabled={updateProfile.isPending}
className="w-full sm:flex-1 cursor-pointer h-10 sm:h-11 text-sm sm:text-base font-medium border-gray-300 hover:bg-gray-50"
>
<X className="h-4 w-4 mr-2" />
{t("cancel")}
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Logout Button */}
<div className="flex justify-center">
<Button
onClick={handleLogout}
variant="destructive"
size="lg"
className="w-full cursor-pointer sm:w-auto sm:min-w-[280px] flex items-center justify-center gap-2 h-11 text-sm sm:text-base font-medium shadow-sm"
>
<LogOut className="h-4 w-4 sm:h-5 sm:w-5" />
{t("common.logout")}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
// import { userStore } from "../userStore";
import type { ProfileResponse, UpdateProfileRequest, UpdateProfileResponse } from "@/lib/types/api";
export const useUserProfile = () => {
return useQuery<ProfileResponse["data"]>({
queryKey: ["user-profile"],
queryFn: async () => {
const response = await apiClient.get<ProfileResponse>("/profile");
const userData = response.data.data;
// Store'a kaydet
// userStore.setUser(userData);
return userData;
},
staleTime: 5 * 60 * 1000,
retry: 1,
});
};
export const useUpdateProfile = () => {
const queryClient = useQueryClient();
return useMutation<UpdateProfileResponse["data"], Error, UpdateProfileRequest>({
mutationFn: async (profileData) => {
const response = await apiClient.post<UpdateProfileResponse>("/profile", profileData);
return response.data.data;
},
onSuccess: (data) => {
// userStore.setUser(data);
queryClient.setQueryData(["user-profile"], data);
queryClient.invalidateQueries({ queryKey: ["user-profile"] });
},
});
};

View File

@@ -0,0 +1,30 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import type { SearchResponse, SearchParams } from "../types";
export function useSearchProducts(params: SearchParams) {
const { q, barcode } = params;
return useQuery({
queryKey: ["search", { q, barcode }],
queryFn: async () => {
if (barcode) {
const response = await apiClient.get<SearchResponse>(
`/search-product-barcode?barcode=${barcode}`
);
return response.data;
}
if (q) {
const response = await apiClient.get<SearchResponse>(
`/search-product?q=${encodeURIComponent(q)}`
);
return response.data;
}
return { message: "success", data: [] };
},
enabled: !!(q && q.length > 0) || !!barcode,
staleTime: 1000 * 60 * 5,
});
}

30
features/search/types.ts Normal file
View File

@@ -0,0 +1,30 @@
// Search Types
export interface SearchProduct {
id: number;
name: string;
stock: number;
cost_amount: string;
price_amount: string;
brand: {
id: number;
name: string;
};
thumbnail: string;
media: Array<{
thumbnail: string;
images_400x400: string;
images_720x720: string;
images_800x800: string;
images_1200x1200: string;
}>;
}
export interface SearchResponse {
message: string;
data: SearchProduct[];
}
export interface SearchParams {
q?: string;
barcode?: string;
}

32
i18n/i18n.ts Normal file
View File

@@ -0,0 +1,32 @@
import { getRequestConfig } from "next-intl/server"
import { notFound } from "next/navigation"
export const locales = ["ru", "tm"] as const
export const defaultLocale = "ru" as const
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale
// Fallback to default if undefined
if (!locale) {
locale = defaultLocale
}
// Validate locale
if (!locales.includes(locale as any)) {
notFound()
}
try {
const messages = (await import(`./messages/${locale}.json`)).default
return {
locale,
messages,
}
} catch (error) {
return {
locale,
messages: {},
}
}
})

196
i18n/messages/ru.json Normal file
View File

@@ -0,0 +1,196 @@
{
"common": {
"categories": "Категории",
"products": "Продукты",
"catalog": "Каталог",
"search": "Поиск продукта",
"orders": "Заказы",
"favorites": "Избранное",
"cart": "Корзина",
"login": "Войти",
"logout": "Выйти",
"profile": "Профиль",
"openStore": "Открыть магазин",
"phone": "Номер телефона",
"code": "Код",
"send": "Отправить",
"enterPhone": "Введите свой номер телефона",
"weWillSendCode": "Мы вышлем вам код",
"loading": "Загрузка...",
"all_collections_loaded": "Все коллекции загружены"
},
"category": "Категория",
"checkout": "Оформить заказ",
"price_label": "Цена:",
"extra_price": "Доп. цена:",
"discount": "Скидка:",
"total_price": "Общая цена:",
"profile": "Профиль",
"cart_orders": "Корзина заказов",
"shipping_method": "Способ доставки",
"product_description_title": "Описание к товару",
"recommended": "Рекомендуем также",
"address_search": "Поиск адреса",
"address": "Адрес",
"first_name": "Имя",
"save": "Сохранить",
"enter_phone": "Введите свой номер телефона",
"code_will_be_sent": "Мы вышлем вам код",
"phone_number": "Номер телефона",
"code": "Код",
"send": "Отправить",
"last_name": "Фамилия",
"cart": "Корзина",
"order": "Заказать",
"delivery_type": "Тип доставки",
"delivery": "Доставка",
"pickup": "Самовывоз",
"payment_type": "Тип оплаты",
"cash": "Наличные",
"card": "Карта",
"choose_address": "Выберите адрес",
"brands": "Бренд",
"color": "Цвет",
"price": "Цена",
"price_from": "От",
"price_to": "До",
"label": "Ярлык",
"about_product": "О товаре",
"model": "Модель",
"product_quantity": "Количество товара:",
"store": "Магазин",
"write_to_store": "Написать в магазин",
"choose_size": "Выберите размер:",
"filter": "Фильтр",
"order_status_draft": "Черновик",
"order_status_placed": "Размещено",
"order_status_assembly": "Сборка",
"order_status_delivery": "Доставка",
"order_status_delivered": "Доставлено",
"order_status_completed": "Завершено",
"order_status_cancelled": "Отменено",
"cancel_order": "Отменить заказ",
"favorite_products": "Избранные",
"are_you_sure": "Вы уверены?",
"no": "Нет",
"yes": "Да",
"cart_empty": "Ваша корзина пуста",
"add_to_cart": "Добавить в корзину",
"go_to_cart": "Перейти в корзину",
"products": "Продукты",
"become_seller": "Стать продавцом",
"choose_region": "Выберите регион",
"choose_or_enter_address": "Выберите или введите свой адрес",
"note": "Заметка",
"seller_application_form": "Форма подачи заявления на открытие магазина",
"phone": "Телефон",
"unit_price": "Цена за 1 шт.:",
"order_available_in_shops": "Имеется заказ в магазинах:",
"subcategories": "Подкатегории",
"sort": "Сортировка",
"default": "По умолчанию",
"price_low_to_high": "От дешевых к дорогим",
"price_high_to_low": "От дорогих к дешевым",
"reset": "Сбросить",
"total": "Всего",
"no_results": "Результатов не найдено",
"close": "Закрыть",
"category_not_found": "Категория не найдена",
"empty_favorites": "У вас пока нет избранных товаров",
"removed_from_favorites": "Товар удален из избранного",
"added_to_cart": "Товар добавлен в корзину",
"failed_to_update_quantity": "Количество не удалось обновить",
"removed_from_cart": "Товар удален из корзины",
"error": "Произошла ошибка",
"out_of_stock": "Нет в наличии",
"personal_info": "Личная информация",
"profile_description": "Ваши данные профиля",
"error_loading_profile": "Не удалось загрузить профиль",
"try_again": "Попробовать снова",
"my_orders": "Мои заказы",
"active_orders": "Активные заказы",
"completed_orders": "Завершенные заказы",
"keep_order": "Оставить заказ",
"cancel_confirmation": "Вы уверены, что хотите отменить этот заказ?",
"cancelling": "Отмена...",
"order_number": "Заказ №",
"no_orders": "У вас пока нет заказов",
"no_active_orders": "У вас нет активных заказов",
"no_completed_orders": "У вас нет завершенных заказов",
"load_orders_error": "Не удалось загрузить заказы",
"order_cancelled": "Заказ отменен",
"order_cancelled_description": "Ваш заказ был успешно отменен",
"cancel_order_failed": "Не удалось отменить заказ",
"delivery_time": "Время доставки",
"delivery_date": "Дата доставки",
"payment_method": "Способ оплаты",
"product_not_found": "Товар не найден",
"product_not_found_description": "Этот товар не существует или был удален",
"no_image": "Нет изображения",
"stock": "Наличие",
"barcode": "Штрих-код",
"product_description": "Описание товара",
"adding": "Добавление...",
"added_to_cart_description": "добавлен в корзину",
"add_to_cart_failed": "Не удалось добавить товар в корзину",
"cart_updated": "Корзина обновлена",
"update_quantity_failed": "Не удалось обновить количество",
"logging_out": "Выход...",
"invalid_phone": "Неверный номер телефона",
"invalid_code": "Неверный код",
"code_sent": "Код отправлен на ваш номер",
"login_success": "Вход выполнен успешно",
"error_occurred": "Произошла ошибка",
"wrong_code": "Неверный код",
"phone_format": "Формат: 65123456",
"sending": "Отправка...",
"verifying": "Проверка...",
"verify": "Подтвердить",
"only_left": "Осталось {count} шт.",
"stock_limit_title": "Недостаточно на складе",
"stock_limit_message": "{product} закончился. Можно купить только {stock} шт.",
"understood": "Понятно",
"loading": "Загрузка...",
"customer_information": "Информация о клиенте",
"name": "Имя",
"edit_your_information": "Изменить информацию",
"view_your_information": "Просмотр информации",
"edit": "Изменить",
"enter_first_name": "Введите имя",
"enter_last_name": "Введите фамилию",
"enter_phone_number": "Введите номер телефона",
"enter_address": "Введите адрес",
"save_changes": "Сохранить изменения",
"saving": "Сохранение...",
"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": "Файл загрузить"
}

195
i18n/messages/tm.json Normal file
View File

@@ -0,0 +1,195 @@
{
"common": {
"categories": "Bölümler",
"products": "Azyk harytlary",
"catalog": "Katalog",
"search": "Haryt gözleg",
"orders": "Sargytlar",
"favorites": "Halanlarym",
"cart": "Sebet",
"login": "Girmek",
"logout": "Çykmak",
"profile": "Profil",
"openStore": "Satyjy bolmak",
"phone": "Telefon",
"code": "Kod",
"send": "Ugrat",
"enterPhone": "Telefon belgisini giriziň",
"weWillSendCode": "Biz size kod ugradarys",
"loading": "Ýüklenýär...",
"all_collections_loaded": "Bütüň koleksiyonlar ýüklendi"
},
"category": "Bölümler",
"checkout": "Sargyt et",
"price_label": "Baha:",
"extra_price": "Goşmaça baha:",
"discount": "Arzanladyş:",
"total_price": "Jemi baha:",
"profile": "Profil",
"shipping_method": "Eltip bermek usuly",
"cart_orders": "Sargyt sebedi",
"product_description_title": "Haryt barada maglumat",
"recommended": "Maslahat berilýän harytlar",
"address_search": "Adres gözleg",
"address": "Adres",
"first_name": "Ady",
"save": "Ýatda sakla",
"enter_phone": "Telefon belgisini giriziň",
"code_will_be_sent": "Biz size kod ugradarys",
"phone_number": "Telefon belgisi",
"code": "Kod",
"send": "Ugrat",
"last_name": "Familiýa",
"cart": "Sebet",
"order": "Sargyt et",
"delivery_type": "Elip bermek görnüşi",
"delivery": "Eltip bermek",
"pickup": "Özüň baryp al",
"payment_type": "Töleg görnüşi",
"cash": "Nagt",
"card": "Kartdan tölemek",
"choose_address": "Adres saýla",
"brands": "Brendler",
"color": "Reňk",
"price": "Baha",
"price_from": "Pesi",
"price_to": "Ýokary",
"label": "Etiket",
"about_product": "Haryt barada",
"model": "Görnüşi",
"product_quantity": "Haryt mukdary:",
"store": "Dükan",
"write_to_store": "Dükana ýaz",
"choose_size": "Ölçegi saýla:",
"filter": "Süzgüç",
"order_status_draft": "Garaşlama",
"order_status_placed": "Ýerleşdirildi",
"order_status_assembly": "Gurnama",
"order_status_delivery": "Eltip bermek",
"order_status_delivered": "Eltilip berildi",
"order_status_completed": "Tamamlandy",
"order_status_cancelled": "Ýatyryldy",
"cancel_order": "Sargydy ýatyrmak",
"favorite_products": "Saýlanan harytlar",
"are_you_sure": "Siz ynamlymy?",
"no": "Ýok",
"yes": "Hawa",
"cart_empty": "Siziň söwda sebediňiz boş",
"add_to_cart": "Sebede goş",
"go_to_cart": "Sebede geçmek",
"products": "Azyk harytlary",
"become_seller": "Satyjy bolmak",
"choose_region": "Etrap saýlaň",
"choose_or_enter_address": "Salgyňyzy saýlaň ýa-da ýazyň",
"note": "Bellik",
"seller_application_form": "Dükan açmak üçin arza görnüşi",
"phone": "Telefon",
"unit_price": "1 san bahasy:",
"order_available_in_shops": "Dükanlarda sargyt bar:",
"subcategories": "Kiçi bölümler",
"sort": "Tertiplemek",
"default": "Adaty",
"price_low_to_high": "Arzan bahadan gymmat bahara",
"price_high_to_low": "Gymmat bahadan arzan bahara",
"reset": "Arassalamak",
"total": "Jemi",
"no_results": "Netije tapylmady",
"close": "Ýap",
"category_not_found": "Bölüm tapylmady",
"empty_favorites": "Siziň saýlanan harytlaryňyz ýok",
"removed_from_favorites": "Haryt saýlanlardan aýryldy",
"added_to_cart": "Haryt sebede goşuldy",
"failed_to_update_quantity": "Mukdar täzelenip bolmady",
"removed_from_cart": "Haryt sebetden aýryldy",
"error": "Ýalňyşlyk ýüze çykdy",
"out_of_stock": "Haryt ýok",
"personal_info": "Şahsy maglumat",
"profile_description": "Siziň profil maglumatlaryňyz",
"error_loading_profile": "Profili ýükläp bolmady",
"try_again": "Täzeden synanyşyň",
"my_orders": "Meniň sargytlarym",
"active_orders": "Işjeň sargytlar",
"completed_orders": "Tamamlanan sargytlar",
"keep_order": "Sargydy saklamak",
"cancel_confirmation": "Siz bu sargydy ýatyrmagy hakykatdanam isleýärsiňizmi?",
"cancelling": "Ýatyrylýar...",
"order_number": "Sargyt №",
"no_orders": "Siziň heniz sargydyňyz ýok",
"no_active_orders": "Siziň işjeň sargydyňyz ýok",
"no_completed_orders": "Siziň tamamlanan sargydyňyz ýok",
"load_orders_error": "Sargytlary ýükläp bolmady",
"order_cancelled": "Sargyt ýatyryldy",
"order_cancelled_description": "Siziň sargydy üstünlikli ýatyryldy",
"cancel_order_failed": "Sargydy ýatyryp bolmady",
"delivery_time": "Eltip berme wagty",
"delivery_date": "Eltip berme senesi",
"payment_method": "Töleg usuly",
"product_not_found": "Haryt tapylmady",
"product_not_found_description": "Bu haryt ýok ýa-da aýryldy",
"no_image": "Surat ýok",
"stock": "Mukdary",
"barcode": "Barkod",
"product_description": "Haryt barada düşündiriş",
"adding": "Goşulýar...",
"added_to_cart_description": "sebede goşuldy",
"add_to_cart_failed": "Haryt sebede goşulmady",
"cart_updated": "Sebet täzelendi",
"update_quantity_failed": "Mukdar täzelenip bolmady",
"logging_out": "Çykylýar...",
"invalid_phone": "Nädogry telefon belgisi",
"invalid_code": "Nädogry kod",
"code_sent": "Kod siziň telefon belgiňize iberildi",
"login_success": "Giriş üstünlikli boldy",
"error_occurred": "Ýalňyşlyk ýüze çykdy",
"wrong_code": "Kod nädogry",
"phone_format": "Format: 65123456",
"sending": "Iberilýär...",
"verifying": "Barlanýar...",
"verify": "Tassyklamak",
"only_left": "Diňe {count} sany galdy",
"stock_limit_title": "Çäkli sanda",
"stock_limit_message": "{product} harytdan diňe {stock} sany bar. Mundan köp sebediňize goşup bilmersiňiz.",
"understood": "Düşündim",
"loading": "Ýüklenýär...",
"customer_information": "Müşteri maglumatlary",
"name": "Ady",
"edit_your_information": "Maglumatlaryňyzy üýtgediň",
"view_your_information": "Maglumatlaryňyzy görüň",
"edit": "Üýtgetmek",
"enter_first_name": "Adyňyzy ýazyň",
"enter_last_name": "Familiýaňyzy ýazyň",
"enter_phone_number": "Telefon belgisini giriziň",
"enter_address": "Adres giriziň",
"save_changes": "Ýatda sakla",
"saving": "Ýatda saklýar...",
"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ş"
}

170
lib/api.ts Normal file
View File

@@ -0,0 +1,170 @@
// lib/api.ts
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from "axios";
import TokenStorage from "./tokenStorage";
const localeToApiLang = (locale: string): string => {
const mapping: Record<string, string> = { tm: "tk", ru: "ru" };
return mapping[locale] || locale;
};
class APIClient {
private client: AxiosInstance;
private baseUrl: string;
private isRefreshing = false;
private failedQueue: Array<{
resolve: (value?: unknown) => void;
reject: (reason?: unknown) => void;
}> = [];
constructor() {
this.baseUrl = process.env.NEXT_PUBLIC_API_URL || "https://api.example.com";
console.log("API URL:", this.baseUrl);
this.client = axios.create({
baseURL: `${this.baseUrl}/api/v1`,
timeout: 15000,
headers: {
"Content-Type": "application/json",
"Api-Token": process.env.NEXT_PUBLIC_API_TOKEN || "123",
},
});
this.setupInterceptors();
}
private setupInterceptors(): void {
// Request interceptor
this.client.interceptors.request.use(
(config) => {
const token = TokenStorage.getActiveToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// Add language parameter
let lang = "tm";
if (typeof window !== "undefined") {
if ((window as any).i18n?.language) {
lang = localeToApiLang((window as any).i18n.language);
} else {
const pathLocale = window.location.pathname.split("/")[1];
if (pathLocale === "tm" || pathLocale === "ru") {
lang = localeToApiLang(pathLocale);
}
}
}
const url = config.url || "";
const separator = url.includes("?") ? "&" : "?";
config.url = `${url}${separator}lang=${lang}`;
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor
this.client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (this.isRefreshing) {
return new Promise((resolve, reject) => {
this.failedQueue.push({ resolve, reject });
})
.then(() => this.client(originalRequest))
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
this.isRefreshing = true;
try {
const guestTokenResponse = await axios.post(
`${this.baseUrl}/api/v1/auth/guest-token`,
{},
{
headers: {
"Content-Type": "application/json",
"Api-Token": process.env.NEXT_PUBLIC_API_TOKEN || "123",
},
}
);
const newToken = guestTokenResponse.data?.token || guestTokenResponse.data?.data;
if (newToken) {
TokenStorage.setGuestToken(newToken);
this.processQueue(null);
return this.client(originalRequest);
}
} catch (refreshError) {
this.processQueue(refreshError);
TokenStorage.clearTokens();
if (typeof window !== "undefined") {
window.location.href = "/login";
}
return Promise.reject(refreshError);
} finally {
this.isRefreshing = false;
}
}
if (
error.response?.data &&
typeof error.response.data === "string" &&
error.response.data.includes("<!DOCTYPE html>")
) {
return Promise.reject({
...error,
response: {
...error.response,
data: { message: "Server returned HTML instead of JSON" },
},
});
}
return Promise.reject(error);
}
);
}
private processQueue(error: any): void {
this.failedQueue.forEach((promise) => {
if (error) {
promise.reject(error);
} else {
promise.resolve();
}
});
this.failedQueue = [];
}
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.get<T>(url, config);
}
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.post<T>(url, data, config);
}
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.put<T>(url, data, config);
}
patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.patch<T>(url, data, config);
}
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<AxiosResponse<T>> {
return this.client.delete<T>(url, config);
}
}
export const apiClient = new APIClient();

25
lib/hooks/index.ts Normal file
View File

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

Some files were not shown because too many files have changed in this diff Show More