first commit
This commit is contained in:
11
src/app/[locale]/auth/page.tsx
Normal file
11
src/app/[locale]/auth/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Login from '@/features/auth/ui/Login';
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<Login />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
11
src/app/[locale]/cart/order/page.tsx
Normal file
11
src/app/[locale]/cart/order/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import OrderPage from '@/features/cart/ui/OrderPage';
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<OrderPage />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
11
src/app/[locale]/cart/page.tsx
Normal file
11
src/app/[locale]/cart/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import CartPage from '@/features/cart/ui/CartPage';
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<CartPage />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
11
src/app/[locale]/category/[categoryId]/[subId]/page.tsx
Normal file
11
src/app/[locale]/category/[categoryId]/[subId]/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Product from '@/features/category/ui/Product';
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<Product />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
11
src/app/[locale]/category/[categoryId]/page.tsx
Normal file
11
src/app/[locale]/category/[categoryId]/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import SubCategory from '@/features/category/ui/SubCategory';
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<SubCategory />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
11
src/app/[locale]/category/page.tsx
Normal file
11
src/app/[locale]/category/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Category from '@/features/category/ui/Category';
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<Category />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
11
src/app/[locale]/favourite/page.tsx
Normal file
11
src/app/[locale]/favourite/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Favourite from '@/features/favourite/ui/Favourite';
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<Favourite />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
27
src/app/[locale]/layout-shell.tsx
Normal file
27
src/app/[locale]/layout-shell.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from '@/shared/config/i18n/navigation';
|
||||
import Footer from '@/widgets/footer/ui';
|
||||
import Navbar from '@/widgets/navbar/ui';
|
||||
|
||||
const HIDE_FOOTER_ROUTES = ['/auth', '/checkout'];
|
||||
|
||||
export default function LayoutShell({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const hideFooter = HIDE_FOOTER_ROUTES.some((route) =>
|
||||
pathname.startsWith(route),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
{children}
|
||||
{!hideFooter && <Footer />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
import '../globals.css';
|
||||
import { golosText } from '@/shared/config/fonts';
|
||||
import { poppins } from '@/shared/config/fonts';
|
||||
import { routing } from '@/shared/config/i18n/routing';
|
||||
import QueryProvider from '@/shared/config/react-query/QueryProvider';
|
||||
import { ThemeProvider } from '@/shared/config/theme-provider';
|
||||
import { PRODUCT_INFO } from '@/shared/constants/data';
|
||||
import type { Metadata } from 'next';
|
||||
import { hasLocale, Locale, NextIntlClientProvider } from 'next-intl';
|
||||
import { routing } from '@/shared/config/i18n/routing';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Footer from '@/widgets/footer/ui';
|
||||
import Navbar from '@/widgets/navbar/ui';
|
||||
import { ReactNode } from 'react';
|
||||
import { setRequestLocale } from 'next-intl/server';
|
||||
import QueryProvider from '@/shared/config/react-query/QueryProvider';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Script from 'next/script';
|
||||
import { ReactNode } from 'react';
|
||||
import '../globals.css';
|
||||
import LayoutShell from './layout-shell';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: PRODUCT_INFO.name,
|
||||
@@ -39,7 +38,7 @@ export default async function RootLayout({ children, params }: Props) {
|
||||
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<body className={`${golosText.variable} antialiased`}>
|
||||
<body className={`${poppins.className} antialiased`}>
|
||||
<NextIntlClientProvider locale={locale}>
|
||||
<ThemeProvider
|
||||
attribute={'class'}
|
||||
@@ -48,9 +47,7 @@ export default async function RootLayout({ children, params }: Props) {
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<QueryProvider>
|
||||
<Navbar />
|
||||
{children}
|
||||
<Footer />
|
||||
<LayoutShell>{children}</LayoutShell>
|
||||
</QueryProvider>
|
||||
</ThemeProvider>
|
||||
</NextIntlClientProvider>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { getPosts } from '@/shared/config/api/testApi';
|
||||
import Welcome from '@/widgets/welcome';
|
||||
import { subCategoriesData } from '@/features/category/lib/data';
|
||||
import { CategoryCarousel } from '@/widgets/categories/ui/category-carousel';
|
||||
import Welcome from '@/widgets/welcome/ui';
|
||||
|
||||
export default async function Home() {
|
||||
const res = await getPosts({ _limit: 1 });
|
||||
console.log('SSR res', res.data);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Welcome />
|
||||
{subCategoriesData.slice(0, 6).map((e) => (
|
||||
<CategoryCarousel category={e} key={e.id} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
11
src/app/[locale]/product/[product]/page.tsx
Normal file
11
src/app/[locale]/product/[product]/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import ProductDetail from '@/features/product/ui/Product';
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<ProductDetail />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
11
src/app/[locale]/profile/page.tsx
Normal file
11
src/app/[locale]/profile/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Profile from '@/features/profile/ui/Profile';
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<Profile />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
11
src/app/[locale]/search/page.tsx
Normal file
11
src/app/[locale]/search/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import SearchResult from '@/features/search/ui/Search';
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<SearchResult />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
BIN
src/assets/banner.webp
Normal file
BIN
src/assets/banner.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
BIN
src/assets/water-bottle.png
Normal file
BIN
src/assets/water-bottle.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
321
src/features/auth/ui/Login.tsx
Normal file
321
src/features/auth/ui/Login.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from '@/shared/config/i18n/navigation';
|
||||
import formatPhone from '@/shared/lib/formatPhone';
|
||||
import { Input } from '@/shared/ui/input';
|
||||
import { ArrowRight, Check, Lock, Phone } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Step = 'phone' | 'otp';
|
||||
|
||||
const Login = () => {
|
||||
const [step, setStep] = useState<Step>('phone');
|
||||
const [phoneNumber, setPhoneNumber] = useState<string>('+998');
|
||||
const [otp, setOtp] = useState<string[]>(['', '', '', '', '', '']);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [countdown, setCountdown] = useState<number>(60);
|
||||
const [canResend, setCanResend] = useState<boolean>(false);
|
||||
const router = useRouter();
|
||||
|
||||
const otpInputs = useRef<Array<HTMLInputElement | null>>([]);
|
||||
|
||||
/* Countdown */
|
||||
useEffect(() => {
|
||||
if (step === 'otp' && countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (countdown === 0) {
|
||||
setCanResend(true);
|
||||
}
|
||||
}, [countdown, step]);
|
||||
|
||||
/* Phone submit */
|
||||
const handlePhoneSubmit = (): void => {
|
||||
setError('');
|
||||
|
||||
if (phoneNumber.length < 9) {
|
||||
setError("Telefon raqamni to'liq kiriting");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
setStep('otp');
|
||||
setCountdown(60);
|
||||
setCanResend(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
/* OTP change */
|
||||
const handleOtpChange = (index: number, value: string): void => {
|
||||
if (value && !/^\d$/.test(value)) return;
|
||||
|
||||
const newOtp = [...otp];
|
||||
newOtp[index] = value;
|
||||
setOtp(newOtp);
|
||||
|
||||
if (value && index < 5) {
|
||||
otpInputs.current[index + 1]?.focus();
|
||||
}
|
||||
|
||||
if (newOtp.every((d) => d !== '') && index === 5) {
|
||||
handleOtpSubmit(newOtp);
|
||||
}
|
||||
};
|
||||
|
||||
/* OTP keydown */
|
||||
const handleOtpKeyDown = (
|
||||
index: number,
|
||||
e: React.KeyboardEvent<HTMLInputElement>,
|
||||
): void => {
|
||||
if (e.key === 'Backspace' && !otp[index] && index > 0) {
|
||||
otpInputs.current[index - 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
/* OTP paste */
|
||||
const handleOtpPaste = (e: React.ClipboardEvent<HTMLDivElement>): void => {
|
||||
e.preventDefault();
|
||||
const pasted = e.clipboardData.getData('text').slice(0, 6);
|
||||
|
||||
if (!/^\d+$/.test(pasted)) return;
|
||||
|
||||
const newOtp = pasted.split('');
|
||||
setOtp([...newOtp, ...Array(6 - newOtp.length).fill('')]);
|
||||
|
||||
const lastIndex = Math.min(newOtp.length - 1, 5);
|
||||
otpInputs.current[lastIndex]?.focus();
|
||||
|
||||
if (pasted.length === 6) {
|
||||
setTimeout(() => handleOtpSubmit(newOtp), 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpSubmit = (otpArray: string[] = otp): void => {
|
||||
setError('');
|
||||
const otpCode = otpArray.join('');
|
||||
|
||||
if (otpCode.length < 6) {
|
||||
setError("Kodni to'liq kiriting");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
|
||||
if (otpCode === '123456') {
|
||||
localStorage.setItem('user', 'true');
|
||||
router.push('/');
|
||||
} else {
|
||||
setError("Noto'g'ri kod. Qayta urinib ko'ring.");
|
||||
setOtp(['', '', '', '', '', '']);
|
||||
otpInputs.current[0]?.focus();
|
||||
}
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
/* Resend */
|
||||
const handleResendOtp = (): void => {
|
||||
if (!canResend) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
setCountdown(60);
|
||||
setCanResend(false);
|
||||
setOtp(['', '', '', '', '', '']);
|
||||
otpInputs.current[0]?.focus();
|
||||
alert('Yangi kod yuborildi!');
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleChangeNumber = (): void => {
|
||||
setStep('phone');
|
||||
setPhoneNumber('');
|
||||
setOtp(['', '', '', '', '', '']);
|
||||
setError('');
|
||||
setCountdown(60);
|
||||
setCanResend(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="custom-container flex justify-center items-center h-[85vh]">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 p-8 text-white text-center">
|
||||
<div className="w-20 h-20 bg-white bg-opacity-20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
{step === 'phone' ? (
|
||||
<Phone className="w-10 h-10" />
|
||||
) : (
|
||||
<Lock className="w-10 h-10" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-2">
|
||||
{step === 'phone' ? 'Xush kelibsiz!' : 'Kodni tasdiqlang'}
|
||||
</h1>
|
||||
<p className="text-blue-100">
|
||||
{step === 'phone'
|
||||
? 'Telefon raqamingizni kiriting'
|
||||
: `${phoneNumber} raqamiga yuborilgan kodni kiriting`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="p-8">
|
||||
{step === 'phone' ? (
|
||||
// Phone Number Step
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Telefon raqam
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<Phone className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
type="tel"
|
||||
value={formatPhone(phoneNumber)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/\D/g, '');
|
||||
setPhoneNumber(value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="+998 90 123-45-67"
|
||||
maxLength={17}
|
||||
className="w-full pl-12 pr-4 py-4 h-12 border-2 border-gray-300 rounded-xl focus:outline-none focus:border-blue-500 transition text-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-red-500 text-sm mt-2">{error}</p>}
|
||||
|
||||
<button
|
||||
onClick={handlePhoneSubmit}
|
||||
disabled={isLoading || phoneNumber.length < 9}
|
||||
className="w-full mt-6 bg-gradient-to-r from-blue-600 to-indigo-600 text-white py-4 rounded-xl font-semibold hover:from-blue-700 hover:to-indigo-700 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Yuborilmoqda...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Kodni olish
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500 mt-6">
|
||||
Davom etish orqali siz bizning{' '}
|
||||
<a href="#" className="text-blue-600 hover:underline">
|
||||
Foydalanish shartlari
|
||||
</a>{' '}
|
||||
va{' '}
|
||||
<a href="#" className="text-blue-600 hover:underline">
|
||||
Maxfiylik siyosati
|
||||
</a>
|
||||
ga rozilik bildirasiz
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
// OTP Step
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-4 text-center">
|
||||
6 raqamli kodni kiriting
|
||||
</label>
|
||||
|
||||
<div
|
||||
className="flex gap-2 justify-center mb-6"
|
||||
onPaste={handleOtpPaste}
|
||||
>
|
||||
{otp.map((digit, index) => (
|
||||
<Input
|
||||
key={index}
|
||||
ref={(el) => {
|
||||
otpInputs.current[index] = el;
|
||||
}}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleOtpChange(index, e.target.value)}
|
||||
onKeyDown={(e) => handleOtpKeyDown(index, e)}
|
||||
className="w-12 h-14 text-center text-2xl font-bold border-2 border-gray-300 rounded-lg focus:outline-none focus:border-blue-500 transition"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg mb-4 text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => handleOtpSubmit()}
|
||||
disabled={isLoading || otp.some((digit) => digit === '')}
|
||||
className="w-full bg-gradient-to-r from-blue-600 to-indigo-600 text-white py-4 rounded-xl font-semibold hover:from-blue-700 hover:to-indigo-700 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Tekshirilmoqda...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Tasdiqlash
|
||||
<Check className="w-5 h-5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Resend OTP */}
|
||||
<div className="mt-6 text-center">
|
||||
{canResend ? (
|
||||
<button
|
||||
onClick={handleResendOtp}
|
||||
disabled={isLoading}
|
||||
className="text-blue-600 hover:text-blue-700 font-semibold hover:underline"
|
||||
>
|
||||
Kodni qayta yuborish
|
||||
</button>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">
|
||||
Kodni qayta yuborish ({countdown}s)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Change Number */}
|
||||
<button
|
||||
onClick={handleChangeNumber}
|
||||
className="w-full mt-4 text-gray-600 hover:text-gray-800 font-medium"
|
||||
>
|
||||
{"Raqamni o'zgartirish"}
|
||||
</button>
|
||||
|
||||
{/* Demo info */}
|
||||
<div className="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-800 text-center">
|
||||
<strong>Demo uchun:</strong>
|
||||
{`Kod sifatida "123456" kiriting`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
318
src/features/cart/ui/CartPage.tsx
Normal file
318
src/features/cart/ui/CartPage.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from '@/shared/config/i18n/navigation';
|
||||
import { Button } from '@/shared/ui/button';
|
||||
import { Input } from '@/shared/ui/input';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CreditCard,
|
||||
Minus,
|
||||
Plus,
|
||||
ShoppingBag,
|
||||
Trash,
|
||||
Truck,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface CartItem {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
oldPrice: number;
|
||||
image: string;
|
||||
quantity: number | string;
|
||||
inStock: boolean;
|
||||
}
|
||||
|
||||
const CartPage = () => {
|
||||
const router = useRouter();
|
||||
const [cartItems, setCartItems] = useState<CartItem[]>([
|
||||
{
|
||||
id: 5,
|
||||
name: 'Coca-Cola 1.5L',
|
||||
price: 12000,
|
||||
oldPrice: 14000,
|
||||
image: '/classic-coca-cola.png',
|
||||
quantity: 2,
|
||||
inStock: true,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Pepsi 2L',
|
||||
price: 11000,
|
||||
oldPrice: 13000,
|
||||
image: '/pepsi-bottle.jpg',
|
||||
quantity: 1,
|
||||
inStock: true,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Sprite 1.5L',
|
||||
price: 10000,
|
||||
oldPrice: 12000,
|
||||
image: '/clear-soda-bottle.png',
|
||||
quantity: 3,
|
||||
inStock: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const subtotal = cartItems.reduce(
|
||||
(sum, item) => sum + item.price * Number(item.quantity),
|
||||
0,
|
||||
);
|
||||
const discount = cartItems.reduce((sum, item) => {
|
||||
if (item.oldPrice) {
|
||||
return sum + (item.oldPrice - item.price) * Number(item.quantity);
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
const deliveryFee = subtotal > 50000 ? 0 : 15000;
|
||||
const total = subtotal - discount + deliveryFee;
|
||||
|
||||
const handleQuantityChange = (id: number, type: 'increase' | 'decrease') => {
|
||||
setCartItems((prev) =>
|
||||
prev.map((item) => {
|
||||
if (item.id === id) {
|
||||
if (type === 'increase')
|
||||
return { ...item, quantity: Number(item.quantity) + 1 };
|
||||
if (type === 'decrease' && Number(item.quantity) > 1)
|
||||
return { ...item, quantity: Number(item.quantity) - 1 };
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Remove item from cart
|
||||
const handleRemoveItem = (id: number) => {
|
||||
setCartItems((prev) => prev.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const handleCheckout = () => {
|
||||
router.push('/cart/order');
|
||||
};
|
||||
|
||||
if (cartItems.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<ShoppingBag className="w-24 h-24 text-gray-300 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
{"Savatingiz bo'sh"}
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{"Mahsulotlar qo'shish uchun katalogga o'ting"}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" /> Xarid qilishni boshlash
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">Savat</h1>
|
||||
<p className="text-gray-600">{cartItems.length} ta mahsulot</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Cart Items */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
{cartItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`p-6 flex relative gap-4 ${index !== cartItems.length - 1 ? 'border-b' : ''}`}
|
||||
>
|
||||
{/* Product Image */}
|
||||
<Button
|
||||
variant={'destructive'}
|
||||
size={'icon'}
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
className="absolute right-2 w-7 h-7 top-2 cursor-pointer"
|
||||
>
|
||||
<Trash className="size-4" />
|
||||
</Button>
|
||||
<div className="w-24 h-40 bg-gray-100 rounded-lg flex-shrink-0 overflow-hidden">
|
||||
<Image
|
||||
width={500}
|
||||
height={500}
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-1">{item.name}</h3>
|
||||
<div className="flex items-center gap-2 mb-3 max-lg:flex-col max-lg:items-start max-lg:gap-1">
|
||||
<span className="text-blue-600 font-bold text-xl">
|
||||
{item.price.toLocaleString()} {"so'm"}
|
||||
</span>
|
||||
{item.oldPrice && (
|
||||
<span className="text-gray-400 line-through text-sm">
|
||||
{item.oldPrice.toLocaleString()} {"so'm"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between max-lg:flex-col max-lg:items-start max-lg:gap-1">
|
||||
{/* Quantity Controls */}
|
||||
<div className="flex items-center border border-gray-300 rounded-lg">
|
||||
<button
|
||||
onClick={() =>
|
||||
handleQuantityChange(item.id, 'decrease')
|
||||
}
|
||||
className="p-2 cursor-pointer transition rounded-lg"
|
||||
disabled={Number(item.quantity) <= 1}
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<Input
|
||||
type="text"
|
||||
min={1}
|
||||
value={item.quantity}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
// Bo'sh qiymatga ruxsat berish
|
||||
if (value === '') {
|
||||
setCartItems((prev) =>
|
||||
prev.map((cartItem) =>
|
||||
cartItem.id === item.id
|
||||
? { ...cartItem, quantity: '' }
|
||||
: cartItem,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const number = parseInt(value, 10);
|
||||
if (!isNaN(number) && number > 0) {
|
||||
setCartItems((prev) =>
|
||||
prev.map((cartItem) =>
|
||||
cartItem.id === item.id
|
||||
? { ...cartItem, quantity: number }
|
||||
: cartItem,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="w-16 text-center outline-none ring-0 focus-visible:ring-0 border-none font-semibold"
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleQuantityChange(item.id, 'increase')
|
||||
}
|
||||
className="p-2 cursor-pointer transition rounded-lg"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow-md p-6 sticky top-4">
|
||||
<h3 className="text-xl font-bold mb-4">Buyurtma xulasasi</h3>
|
||||
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Mahsulotlar narxi:</span>
|
||||
<span>
|
||||
{subtotal.toLocaleString()} {"so'm"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{discount > 0 && (
|
||||
<div className="flex justify-between text-green-600">
|
||||
<span>Chegirma:</span>
|
||||
<span>
|
||||
-{discount.toLocaleString()} {"so'm"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span className="flex items-center gap-1">
|
||||
<Truck className="w-4 h-4" />
|
||||
Yetkazib berish:
|
||||
</span>
|
||||
<span>
|
||||
{deliveryFee === 0 ? (
|
||||
<span className="text-green-600 font-semibold">
|
||||
Bepul
|
||||
</span>
|
||||
) : (
|
||||
`${deliveryFee.toLocaleString()} so'm`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{deliveryFee > 0 && (
|
||||
<p className="text-sm text-gray-500 bg-blue-50 p-2 rounded">
|
||||
{
|
||||
"50,000 so'mdan ortiq xarid qiling va yetkazib berishni bepul oling!"
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4 mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-semibold">Jami:</span>
|
||||
<span className="text-2xl font-bold text-blue-600">
|
||||
{total.toLocaleString()} {"so'm"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCheckout}
|
||||
className="w-full bg-blue-600 text-white py-4 rounded-lg font-semibold hover:bg-blue-700 transition flex items-center justify-center gap-2 mb-4"
|
||||
>
|
||||
<CreditCard className="w-5 h-5" />
|
||||
Buyurtmani rasmiylashtirish
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full border-2 border-gray-300 cursor-pointer text-gray-700 py-3 rounded-lg font-semibold hover:bg-gray-50 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
Xaridni davom ettirish
|
||||
</button>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="mt-6 space-y-3 text-sm text-gray-600">
|
||||
<div className="flex items-start gap-2">
|
||||
<Truck className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<span>Tez yetkazib berish 1-2 kun ichida</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<CreditCard className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<span>{"Xavfsiz to'lov usullari"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartPage;
|
||||
437
src/features/cart/ui/OrderPage.tsx
Normal file
437
src/features/cart/ui/OrderPage.tsx
Normal file
@@ -0,0 +1,437 @@
|
||||
'use client';
|
||||
|
||||
import formatPhone from '@/shared/lib/formatPhone';
|
||||
import { Input } from '@/shared/ui/input';
|
||||
import { Label } from '@/shared/ui/label';
|
||||
import { Textarea } from '@/shared/ui/textarea';
|
||||
import {
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
CreditCard,
|
||||
MapPin,
|
||||
Package,
|
||||
Truck,
|
||||
User,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
|
||||
const OrderPage = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
fullName: '',
|
||||
phone: '+998',
|
||||
email: '',
|
||||
city: '',
|
||||
address: '',
|
||||
postalCode: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const [paymentMethod, setPaymentMethod] = useState('cash');
|
||||
const [deliveryMethod, setDeliveryMethod] = useState('standard');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [orderSuccess, setOrderSuccess] = useState(false);
|
||||
|
||||
// Cart items from previous page (in real app, this would come from context/store)
|
||||
const cartItems = [
|
||||
{
|
||||
id: 5,
|
||||
name: 'Coca-Cola 1.5L',
|
||||
price: 12000,
|
||||
quantity: 2,
|
||||
image: '/classic-coca-cola.png',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Pepsi 2L',
|
||||
price: 11000,
|
||||
quantity: 1,
|
||||
image: '/pepsi-bottle.jpg',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Sprite 1.5L',
|
||||
price: 10000,
|
||||
quantity: 3,
|
||||
image: '/clear-soda-bottle.png',
|
||||
},
|
||||
];
|
||||
|
||||
const subtotal = cartItems.reduce(
|
||||
(sum, item) => sum + item.price * item.quantity,
|
||||
0,
|
||||
);
|
||||
const deliveryFee =
|
||||
deliveryMethod === 'express' ? 25000 : subtotal > 50000 ? 0 : 15000;
|
||||
const total = subtotal + deliveryFee;
|
||||
|
||||
const handleInputChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
setIsSubmitting(false);
|
||||
setOrderSuccess(true);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
if (orderSuccess) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full text-center">
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle2 className="w-12 h-12 text-green-600" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
Buyurtma qabul qilindi!
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Buyurtma raqami:{' '}
|
||||
<span className="font-bold">
|
||||
#ORD-{Math.floor(Math.random() * 10000)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-gray-500 mb-6">
|
||||
Buyurtmangiz muvaffaqiyatli qabul qilindi. Tez orada sizga aloqaga
|
||||
chiqamiz.
|
||||
</p>
|
||||
<div className="bg-blue-50 p-4 rounded-lg mb-6">
|
||||
<p className="text-sm text-gray-700">
|
||||
Buyurtma holati haqida SMS orqali xabardor qilinasiz
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => (window.location.href = '/')}
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 transition"
|
||||
>
|
||||
Bosh sahifaga qaytish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 py-8">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
Buyurtmani rasmiylashtirish
|
||||
</h1>
|
||||
<p className="text-gray-600">{"Ma'lumotlaringizni to'ldiring"}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left Column - Forms */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Contact Information */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<User className="w-5 h-5 text-blue-600" />
|
||||
<h2 className="text-xl font-semibold">
|
||||
{"Shaxsiy ma'lumotlar"}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{"To'liq ism"}
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="fullName"
|
||||
value={formData.fullName}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
className="w-full border h-12 border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:border-blue-500"
|
||||
placeholder="Ismingiz va familiyangiz"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Telefon raqam
|
||||
</Label>
|
||||
<Input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formatPhone(formData.phone)}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
className="w-full h-12 border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:border-blue-500"
|
||||
placeholder="+998 90 123 45 67"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delivery Address */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<MapPin className="w-5 h-5 text-blue-600" />
|
||||
<h2 className="text-xl font-semibold">
|
||||
Yetkazib berish manzili
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Shahar
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
name="city"
|
||||
value={formData.city}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
className="w-full border h-12 border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:border-blue-500"
|
||||
placeholder="Toshkent"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{"To'liq manzil"}
|
||||
</Label>
|
||||
<Textarea
|
||||
name="address"
|
||||
value={formData.address}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
rows={3}
|
||||
className="w-full border min-h-32 max-h-44 border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:border-blue-500"
|
||||
placeholder="Ko'cha, uy raqami, xonadon..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Truck className="w-5 h-5 text-blue-600" />
|
||||
<h2 className="text-xl font-semibold">
|
||||
Yetkazib berish usuli
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center p-4 border-2 rounded-lg cursor-pointer transition hover:bg-gray-50">
|
||||
<Input
|
||||
type="radio"
|
||||
name="delivery"
|
||||
value="standard"
|
||||
checked={deliveryMethod === 'standard'}
|
||||
onChange={(e) => setDeliveryMethod(e.target.value)}
|
||||
className="w-4 h-4 text-blue-600"
|
||||
/>
|
||||
<div className="ml-4 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-gray-600" />
|
||||
<span className="font-semibold">
|
||||
Standart yetkazib berish
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-bold text-blue-600">
|
||||
{subtotal > 50000 ? 'Bepul' : "15,000 so'm"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
2-3 kun ichida
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center p-4 border-2 rounded-lg cursor-pointer transition hover:bg-gray-50">
|
||||
<Input
|
||||
type="radio"
|
||||
name="delivery"
|
||||
value="express"
|
||||
checked={deliveryMethod === 'express'}
|
||||
onChange={(e) => setDeliveryMethod(e.target.value)}
|
||||
className="w-4 h-4 text-blue-600"
|
||||
/>
|
||||
<div className="ml-4 flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="w-5 h-5 text-gray-600" />
|
||||
<span className="font-semibold">
|
||||
Tez yetkazib berish
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-bold text-blue-600">
|
||||
{"25,000 so'm"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">1 kun ichida</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<CreditCard className="w-5 h-5 text-blue-600" />
|
||||
<h2 className="text-xl font-semibold">{"To'lov usuli"}</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center p-4 border-2 rounded-lg cursor-pointer transition hover:bg-gray-50">
|
||||
<Input
|
||||
type="radio"
|
||||
name="payment"
|
||||
value="cash"
|
||||
checked={paymentMethod === 'cash'}
|
||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
className="w-4 h-4 text-blue-600"
|
||||
/>
|
||||
<div className="ml-4 flex items-center gap-3">
|
||||
<Wallet className="w-6 h-6 text-green-600" />
|
||||
<div>
|
||||
<span className="font-semibold">Naqd pul</span>
|
||||
<p className="text-sm text-gray-500">
|
||||
{"Yetkazib berishda to'lash"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label className="flex items-center p-4 border-2 rounded-lg cursor-pointer transition hover:bg-gray-50">
|
||||
<Input
|
||||
type="radio"
|
||||
name="payment"
|
||||
value="card"
|
||||
checked={paymentMethod === 'card'}
|
||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
className="w-4 h-4 text-blue-600"
|
||||
/>
|
||||
<div className="ml-4 flex items-center gap-3">
|
||||
<CreditCard className="w-6 h-6 text-blue-600" />
|
||||
<div>
|
||||
<span className="font-semibold">Plastik karta</span>
|
||||
<p className="text-sm text-gray-500">
|
||||
{"Online to'lov"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<Label className="flex items-center p-4 border-2 rounded-lg cursor-pointer transition hover:bg-gray-50">
|
||||
<Input
|
||||
type="radio"
|
||||
name="payment"
|
||||
value="terminal"
|
||||
checked={paymentMethod === 'terminal'}
|
||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
className="w-4 h-4 text-blue-600"
|
||||
/>
|
||||
<div className="ml-4 flex items-center gap-3">
|
||||
<Building2 className="w-6 h-6 text-purple-600" />
|
||||
<div>
|
||||
<span className="font-semibold">Terminal orqali</span>
|
||||
<p className="text-sm text-gray-500">
|
||||
Yetkazib berishda terminal
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Order Summary */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-lg shadow-md p-6 sticky top-4">
|
||||
<h3 className="text-xl font-bold mb-4">Mahsulotlar</h3>
|
||||
|
||||
{/* Cart Items */}
|
||||
<div className="space-y-3 mb-4 max-h-60 overflow-y-auto">
|
||||
{cartItems.map((item) => (
|
||||
<div key={item.id} className="flex gap-3 pb-3 border-b">
|
||||
<Image
|
||||
width={500}
|
||||
height={500}
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="w-16 h-16 object-contain bg-gray-100 rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-sm">{item.name}</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
{item.quantity} x {item.price.toLocaleString()}{' '}
|
||||
{"so'm"}
|
||||
</p>
|
||||
<p className="font-semibold text-sm">
|
||||
{(item.price * item.quantity).toLocaleString()}{' '}
|
||||
{"so'm"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="space-y-2 mb-4 pt-4 border-t">
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Mahsulotlar:</span>
|
||||
<span>
|
||||
{subtotal.toLocaleString()} {"so'm"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-gray-600">
|
||||
<span>Yetkazib berish:</span>
|
||||
<span>
|
||||
{deliveryFee === 0 ? (
|
||||
<span className="text-green-600 font-semibold">
|
||||
Bepul
|
||||
</span>
|
||||
) : (
|
||||
`${deliveryFee.toLocaleString()} so'm`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4 mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-semibold">Jami:</span>
|
||||
<span className="text-2xl font-bold text-blue-600">
|
||||
{total.toLocaleString()} {"so'm"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-blue-600 text-white py-4 rounded-lg font-semibold hover:bg-blue-700 transition disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Yuborilmoqda...
|
||||
</span>
|
||||
) : (
|
||||
'Buyurtmani tasdiqlash'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderPage;
|
||||
1099
src/features/category/lib/data.ts
Normal file
1099
src/features/category/lib/data.ts
Normal file
File diff suppressed because it is too large
Load Diff
36
src/features/category/ui/Category.tsx
Normal file
36
src/features/category/ui/Category.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
import { useRouter } from '@/shared/config/i18n/navigation';
|
||||
import { categoryList, CategoryType } from '@/widgets/welcome/lib/data';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
|
||||
const Category = () => {
|
||||
const router = useRouter();
|
||||
const handleCategoryClick = (category: CategoryType) => {
|
||||
router.push(`/category/${category.name}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">
|
||||
Kategoriyalar
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{categoryList.map((category, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleCategoryClick(category)}
|
||||
className="bg-white border border-gray-200 rounded-lg p-4 flex items-center justify-between hover:border-gray-300 transition-colors"
|
||||
>
|
||||
<span className="text-gray-900 font-medium">{category.name}</span>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Category;
|
||||
76
src/features/category/ui/Product.tsx
Normal file
76
src/features/category/ui/Product.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from '@/shared/config/i18n/navigation';
|
||||
import { ProductCard } from '@/widgets/categories/ui/product-card';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { subCategoriesData } from '../lib/data';
|
||||
|
||||
const Product = () => {
|
||||
const { subId } = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
const decodedSubId = decodeURIComponent(subId as string);
|
||||
|
||||
const subCategory =
|
||||
subCategoriesData.find((cat) => cat.name === decodedSubId) ||
|
||||
subCategoriesData[0];
|
||||
const [products, setProducts] = useState(subCategory.products);
|
||||
const handleBack = () => {
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleRemove = (id: number) => {
|
||||
setProducts((prev) =>
|
||||
prev.map((product) =>
|
||||
product.id === id ? { ...product, liked: false } : product,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleLiked = (id: number) => {
|
||||
setProducts((prev) =>
|
||||
prev.map((product) =>
|
||||
product.id === id ? { ...product, liked: true } : product,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="custom-container p-4 mb-5">
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>Orqaga</span>
|
||||
</button>
|
||||
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
{decodedSubId}
|
||||
</h1>
|
||||
<p className="text-gray-600 text-sm mt-1">
|
||||
{subCategory.products.length} ta mahsulot
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
handleRemove={handleRemove}
|
||||
handleLiked={handleLiked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Product;
|
||||
45
src/features/category/ui/SubCategory.tsx
Normal file
45
src/features/category/ui/SubCategory.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from '@/shared/config/i18n/navigation';
|
||||
import { categoryList } from '@/widgets/welcome/lib/data';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useParams } from 'next/navigation';
|
||||
|
||||
const SubCategory = () => {
|
||||
const { categoryId } = useParams();
|
||||
|
||||
const router = useRouter();
|
||||
const category =
|
||||
categoryList.find((cat) => cat.name === categoryId) || categoryList[0];
|
||||
|
||||
const handleSubCategoryClick = (subCategory: { name: string }) => {
|
||||
router.push(`/category/${categoryId}/${subCategory.name}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-6">
|
||||
{category.name}
|
||||
</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{category.subCategories.map((subCategory, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSubCategoryClick(subCategory)}
|
||||
className="bg-white border border-gray-200 rounded-lg p-4 flex items-center justify-between hover:border-gray-300 transition-colors"
|
||||
>
|
||||
<span className="text-gray-900 font-medium">
|
||||
{subCategory.name}
|
||||
</span>
|
||||
<ChevronRight className="w-5 h-5 text-gray-400" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubCategory;
|
||||
145
src/features/favourite/ui/Favourite.tsx
Normal file
145
src/features/favourite/ui/Favourite.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from '@/shared/config/i18n/navigation';
|
||||
import { Button } from '@/shared/ui/button';
|
||||
import { ProductCard } from '@/widgets/categories/ui/product-card';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
// Fake data
|
||||
const LIKED_PRODUCTS = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Samsung Galaxy S23 Ultra 256GB, Phantom Black',
|
||||
price: 12500000,
|
||||
oldPrice: 15000000,
|
||||
image: 'https://images.unsplash.com/photo-1610945415295-d9bbf067e59c?w=400',
|
||||
rating: 4.8,
|
||||
reviews: 342,
|
||||
discount: 17,
|
||||
inStock: true,
|
||||
liked: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Apple AirPods Pro 2-chi avlod (USB-C)',
|
||||
price: 2850000,
|
||||
oldPrice: 3200000,
|
||||
image: 'https://images.unsplash.com/photo-1606841837239-c5a1a4a07af7?w=400',
|
||||
rating: 4.9,
|
||||
reviews: 567,
|
||||
discount: 11,
|
||||
liked: true,
|
||||
inStock: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Sony PlayStation 5 Slim 1TB + 2 ta o'yin",
|
||||
price: 7500000,
|
||||
oldPrice: 8500000,
|
||||
image: 'https://images.unsplash.com/photo-1606813907291-d86efa9b94db?w=400',
|
||||
rating: 4.7,
|
||||
reviews: 234,
|
||||
discount: 12,
|
||||
inStock: true,
|
||||
liked: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'MacBook Air 13 M2 chip, 8GB RAM, 256GB SSD',
|
||||
price: 14200000,
|
||||
oldPrice: 16000000,
|
||||
image: 'https://images.unsplash.com/photo-1517336714731-489689fd1ca8?w=400',
|
||||
rating: 4.9,
|
||||
reviews: 891,
|
||||
liked: true,
|
||||
discount: 11,
|
||||
inStock: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Dyson V15 Detect Simsiz Changyutgich',
|
||||
price: 6800000,
|
||||
oldPrice: 7800000,
|
||||
image: 'https://images.unsplash.com/photo-1558317374-067fb5f30001?w=400',
|
||||
rating: 4.6,
|
||||
reviews: 178,
|
||||
discount: 13,
|
||||
liked: true,
|
||||
inStock: false,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Nike Air Max 270 React Erkaklar Krosovkasi',
|
||||
price: 1250000,
|
||||
oldPrice: 1650000,
|
||||
image: 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=400',
|
||||
rating: 4.5,
|
||||
liked: true,
|
||||
reviews: 423,
|
||||
discount: 24,
|
||||
inStock: true,
|
||||
},
|
||||
];
|
||||
|
||||
export default function Favourite() {
|
||||
const [likedProducts, setLikedProducts] = useState(LIKED_PRODUCTS);
|
||||
const router = useRouter();
|
||||
|
||||
const handleRemove = (id: number) => {
|
||||
setLikedProducts((prev) => prev.filter((product) => product.id !== id));
|
||||
};
|
||||
|
||||
if (likedProducts.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 py-12">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="w-32 h-32 bg-slate-100 rounded-full flex items-center justify-center mb-6">
|
||||
<Heart className="w-16 h-16 text-slate-300" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-800 mb-2">
|
||||
{"Sevimlilar bo'sh"}
|
||||
</h2>
|
||||
<p className="text-slate-500 text-center max-w-md mb-8">
|
||||
{`Hali hech qanday mahsulotni sevimlilarga qo'shmadingiz.
|
||||
Mahsulotlar ro'yxatiga o'ting va yoqqan mahsulotlaringizni
|
||||
saqlang.`}
|
||||
</p>
|
||||
<Button
|
||||
className="bg-blue-600 text-white px-8 py-3 rounded-xl hover:bg-blue-700"
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
Xarid qilishni boshlash
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 py-8">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-800 mb-2">
|
||||
Sevimli mahsulotlar
|
||||
</h1>
|
||||
<p className="text-slate-500">{likedProducts.length} ta mahsulot</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{likedProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
handleRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
360
src/features/product/ui/Product.tsx
Normal file
360
src/features/product/ui/Product.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
'use client';
|
||||
|
||||
import { Carousel, CarouselContent, CarouselItem } from '@/shared/ui/carousel';
|
||||
import { ProductCard } from '@/widgets/categories/ui/product-card';
|
||||
import {
|
||||
Heart,
|
||||
Minus,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Shield,
|
||||
ShoppingCart,
|
||||
Star,
|
||||
Truck,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useState } from 'react';
|
||||
|
||||
const ProductDetail = () => {
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
const [liked, setLiked] = useState(false);
|
||||
|
||||
// Fake product data
|
||||
const product = {
|
||||
id: 5,
|
||||
name: 'Coca-Cola 1.5L',
|
||||
price: 12000,
|
||||
oldPrice: 14000,
|
||||
image: '/classic-coca-cola.png',
|
||||
rating: 4.8,
|
||||
reviews: 342,
|
||||
discount: 14,
|
||||
inStock: true,
|
||||
description:
|
||||
"Coca-Cola klassik ta'mi bilan ajoyib gazlangan ichimlik. 1.5 litrlik shisha butilkada. Sovuq holda iste'mol qilish tavsiya etiladi.",
|
||||
category: 'Ichimliklar',
|
||||
brand: 'Coca-Cola',
|
||||
volume: '1.5L',
|
||||
images: [
|
||||
'/classic-coca-cola.png',
|
||||
'/clear-soda-bottle.png',
|
||||
'/classic-coca-cola.png',
|
||||
'/classic-coca-cola.png',
|
||||
'/classic-coca-cola.png',
|
||||
'/classic-coca-cola.png',
|
||||
'/classic-coca-cola.png',
|
||||
'/classic-coca-cola.png',
|
||||
'/classic-coca-cola.png',
|
||||
'/classic-coca-cola.png',
|
||||
'/classic-coca-cola.png',
|
||||
],
|
||||
specifications: {
|
||||
Hajmi: '1.5 litr',
|
||||
'Qadoq turi': 'Plastik butilka',
|
||||
'Ishlab chiqaruvchi': 'Coca-Cola Company',
|
||||
'Saqlash muddati': '12 oy',
|
||||
'Energiya qiymati': '180 kJ / 43 kcal',
|
||||
},
|
||||
};
|
||||
|
||||
const [relatedProducts, setRelatedProducts] = useState([
|
||||
{
|
||||
id: 6,
|
||||
name: 'Pepsi 2L',
|
||||
price: 11000,
|
||||
reviews: 342,
|
||||
liked: false,
|
||||
inStock: true,
|
||||
oldPrice: 13000,
|
||||
image: '/pepsi-bottle.jpg',
|
||||
rating: 4.6,
|
||||
discount: 15,
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Sprite 1.5L',
|
||||
price: 10000,
|
||||
inStock: true,
|
||||
oldPrice: 12000,
|
||||
image: '/clear-soda-bottle.png',
|
||||
rating: 4.5,
|
||||
reviews: 342,
|
||||
liked: false,
|
||||
discount: 17,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Fanta Orange 1L',
|
||||
price: 9000,
|
||||
oldPrice: 10000,
|
||||
inStock: true,
|
||||
image: '/fanta-orange-bottle.png',
|
||||
rating: 4.4,
|
||||
reviews: 342,
|
||||
liked: true,
|
||||
discount: 10,
|
||||
},
|
||||
]);
|
||||
|
||||
const handleQuantityChange = (type: string) => {
|
||||
if (type === 'increase') {
|
||||
setQuantity(quantity + 1);
|
||||
} else if (type === 'decrease' && quantity > 1) {
|
||||
setQuantity(quantity - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const addToCart = () => {
|
||||
alert(`${quantity} ta ${product.name} savatchaga qo'shildi!`);
|
||||
};
|
||||
|
||||
const handleRemove = (id: number) => {
|
||||
setRelatedProducts((prev) =>
|
||||
prev.map((product) =>
|
||||
product.id === id ? { ...product, liked: false } : product,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleLiked = (id: number) => {
|
||||
setRelatedProducts((prev) =>
|
||||
prev.map((product) =>
|
||||
product.id === id ? { ...product, liked: true } : product,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="custom-container pb-5">
|
||||
<div className="">
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<div className="relative bg-gray-100 rounded-lg overflow-hidden mb-4">
|
||||
<Image
|
||||
width={500}
|
||||
height={500}
|
||||
src={product.images[selectedImage]}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{product.discount > 0 && (
|
||||
<div className="absolute top-4 left-4 bg-red-500 text-white px-3 py-1 rounded-full text-sm font-semibold">
|
||||
-{product.discount}%
|
||||
</div>
|
||||
)}
|
||||
{!product.inStock && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<span className="text-white text-xl font-bold">
|
||||
Mavjud emas
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Carousel
|
||||
opts={{
|
||||
align: 'start',
|
||||
dragFree: true,
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<CarouselContent className="-ml-2">
|
||||
{product.images.map((img, index) => (
|
||||
<CarouselItem
|
||||
key={index}
|
||||
className="pl-2 basis-1/3 sm:basis-1/4 md:basis-1/5 lg:basis-1/6"
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelectedImage(index)}
|
||||
className={`aspect-square w-full rounded-lg border-2 overflow-hidden transition ${
|
||||
selectedImage === index
|
||||
? 'border-blue-500'
|
||||
: 'border-gray-200 hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
src={img}
|
||||
alt={`thumb-${index}`}
|
||||
width={150}
|
||||
height={150}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</button>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
{product.name}
|
||||
</h1>
|
||||
|
||||
{/* Rating */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="flex items-center">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-5 h-5 ${
|
||||
i < Math.floor(product.rating)
|
||||
? 'fill-yellow-400 text-yellow-400'
|
||||
: 'text-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-gray-600">{product.rating}</span>
|
||||
<span className="text-gray-400">({product.reviews} sharh)</span>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-4xl font-bold text-blue-600">
|
||||
{product.price.toLocaleString()} {"so'm"}
|
||||
</span>
|
||||
{product.oldPrice && (
|
||||
<span className="text-xl text-gray-400 line-through">
|
||||
{product.oldPrice.toLocaleString()} {"so'm"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-gray-600 mb-6">{product.description}</p>
|
||||
|
||||
{/* Brand and Category */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<span className="text-gray-500">Brand:</span>
|
||||
<p className="font-semibold">{product.brand}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Kategoriya:</span>
|
||||
<p className="font-semibold">{product.category}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Selector */}
|
||||
<div className="mb-6">
|
||||
<label className="text-gray-700 font-medium mb-2 block">
|
||||
Miqdor:
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center border border-gray-300 rounded-lg">
|
||||
<button
|
||||
onClick={() => handleQuantityChange('decrease')}
|
||||
className="p-3 hover:bg-gray-100 transition"
|
||||
disabled={quantity <= 1}
|
||||
>
|
||||
<Minus className="w-5 h-5" />
|
||||
</button>
|
||||
<span className="px-6 font-semibold text-lg">
|
||||
{quantity}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleQuantityChange('increase')}
|
||||
className="p-3 hover:bg-gray-100 transition"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-gray-600">
|
||||
Jami:{' '}
|
||||
<span className="font-bold text-lg">
|
||||
{(product.price * quantity).toLocaleString()} {"so'm"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
<button
|
||||
onClick={addToCart}
|
||||
disabled={!product.inStock}
|
||||
className={`flex-1 py-4 rounded-lg font-semibold text-white flex items-center justify-center gap-2 transition ${
|
||||
product.inStock
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
: 'bg-gray-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
{"Savatchaga qo'shish"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setLiked(!liked)}
|
||||
className={`p-4 rounded-lg border-2 transition ${
|
||||
liked
|
||||
? 'border-red-500 bg-red-50'
|
||||
: 'border-gray-300 hover:border-red-500'
|
||||
}`}
|
||||
>
|
||||
<Heart
|
||||
className={`w-6 h-6 ${liked ? 'fill-red-500 text-red-500' : 'text-gray-600'}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="grid grid-cols-3 gap-4 border-t pt-6">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<Truck className="w-8 h-8 text-blue-600 mb-2" />
|
||||
<span className="text-sm text-gray-600">
|
||||
Bepul yetkazib berish
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<Shield className="w-8 h-8 text-blue-600 mb-2" />
|
||||
<span className="text-sm text-gray-600">Kafolat</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<RotateCcw className="w-8 h-8 text-blue-600 mb-2" />
|
||||
<span className="text-sm text-gray-600">
|
||||
14 kun qaytarish
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Specifications */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||
<h2 className="text-2xl font-bold mb-4">Xususiyatlari</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(product.specifications).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between border-b pb-2">
|
||||
<span className="text-gray-600">{key}:</span>
|
||||
<span className="font-semibold">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Products */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-2xl font-bold mb-6">{"O'xshash mahsulotlar"}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{relatedProducts.map((item) => (
|
||||
<ProductCard
|
||||
key={item.id}
|
||||
product={item}
|
||||
handleRemove={handleRemove}
|
||||
handleLiked={handleLiked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetail;
|
||||
1069
src/features/profile/ui/Profile.tsx
Normal file
1069
src/features/profile/ui/Profile.tsx
Normal file
File diff suppressed because it is too large
Load Diff
156
src/features/search/ui/Search.tsx
Normal file
156
src/features/search/ui/Search.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/shared/ui/input';
|
||||
import {
|
||||
categories,
|
||||
Product,
|
||||
ProductDetail,
|
||||
} from '@/widgets/categories/lib/data';
|
||||
import { ProductCard } from '@/widgets/categories/ui/product-card';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const SearchResult: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const queryFromUrl = searchParams.get('q') ?? '';
|
||||
|
||||
const [query, setQuery] = useState(queryFromUrl);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [results, setResults] = useState<ProductDetail[]>([]);
|
||||
|
||||
const allProducts = useMemo<ProductDetail[]>(() => {
|
||||
return categories.flatMap((category: Product) =>
|
||||
category.products.map((product) => ({
|
||||
...product,
|
||||
categoryName: category.name,
|
||||
})),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const recommendedProducts = useMemo<ProductDetail[]>(() => {
|
||||
return allProducts.filter((product) => product.rating >= 4.5).slice(0, 8);
|
||||
}, [allProducts]);
|
||||
|
||||
const handleSearch = (searchQuery: string) => {
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery)}`);
|
||||
|
||||
setTimeout(() => {
|
||||
const filtered = allProducts.filter(
|
||||
(product) =>
|
||||
product.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
product.categoryName
|
||||
?.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
setResults(filtered);
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (queryFromUrl) {
|
||||
handleSearch(queryFromUrl);
|
||||
}
|
||||
}, [queryFromUrl]);
|
||||
|
||||
const clearSearch = () => {
|
||||
setQuery('');
|
||||
setResults([]);
|
||||
router.push('/search');
|
||||
};
|
||||
|
||||
const handleRemove = (id: number) => {
|
||||
setResults((prev) =>
|
||||
prev.map((product) =>
|
||||
product.id === id ? { ...product, liked: false } : product,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleLiked = (id: number) => {
|
||||
setResults((prev) =>
|
||||
prev.map((product) =>
|
||||
product.id === id ? { ...product, liked: true } : product,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="custom-container justify-center items-center h-screen">
|
||||
<div className="lg:hidden">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
|
||||
<Input
|
||||
value={query}
|
||||
placeholder="Mahsulot nomi"
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
handleSearch(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSearch(query);
|
||||
}}
|
||||
className="w-full border rounded-lg pl-10 pr-10 h-12"
|
||||
/>
|
||||
|
||||
{query && (
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
className="absolute right-7 top-1/2 -translate-y-1/2"
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-8">
|
||||
{loading ? (
|
||||
<div className="text-center py-20">Yuklanmoqda...</div>
|
||||
) : query ? (
|
||||
results.length ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{results.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
handleRemove={handleRemove}
|
||||
handleLiked={handleLiked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">Natija topilmadi</div>
|
||||
)
|
||||
) : (
|
||||
<div className="lg:hidden">
|
||||
<h2 className="text-lg font-semibold mb-4">
|
||||
Tavsiya etilgan mahsulotlar
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{recommendedProducts.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
handleRemove={handleRemove}
|
||||
handleLiked={handleLiked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchResult;
|
||||
@@ -11,14 +11,11 @@ const httpClient = axios.create({
|
||||
|
||||
httpClient.interceptors.request.use(
|
||||
async (config) => {
|
||||
console.log(`API REQUEST to ${config.url}`, config);
|
||||
|
||||
// Language configs
|
||||
let language = LanguageRoutes.UZ;
|
||||
try {
|
||||
language = (await getLocale()) as LanguageRoutes;
|
||||
} catch (e) {
|
||||
console.log('error', e);
|
||||
} catch {
|
||||
language = getLocaleCS() || LanguageRoutes.UZ;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Golos_Text } from 'next/font/google';
|
||||
import { Poppins } from 'next/font/google';
|
||||
|
||||
const golosText = Golos_Text({
|
||||
const poppins = Poppins({
|
||||
weight: ['400', '500', '600', '700', '800'],
|
||||
variable: '--font-golos-text',
|
||||
subsets: ['latin', 'cyrillic'],
|
||||
variable: '--font-poppins',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export { golosText };
|
||||
export { poppins };
|
||||
|
||||
@@ -33,7 +33,7 @@ const useCloser = (
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [ref, closeFunction]);
|
||||
}, [ref, closeFunction, scrollClose]);
|
||||
};
|
||||
|
||||
export default useCloser;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LanguageRoutes } from '../config/i18n/types';
|
||||
import { getLocale } from 'next-intl/server';
|
||||
import getLocaleCS from './getLocaleCS';
|
||||
|
||||
/**
|
||||
* Format price. With label.
|
||||
@@ -7,12 +7,12 @@ import { getLocale } from 'next-intl/server';
|
||||
* @param withLabel Show label. Default false
|
||||
* @returns string. Ex. X XXX XXX sum
|
||||
*/
|
||||
const formatPrice = async (amount: number | string, withLabel?: boolean) => {
|
||||
const locale = (await getLocale()) as LanguageRoutes;
|
||||
const formatPrice = (amount: number | string, withLabel?: boolean) => {
|
||||
const locale = getLocaleCS() as LanguageRoutes;
|
||||
const label = withLabel
|
||||
? locale == LanguageRoutes.RU
|
||||
? locale === LanguageRoutes.RU
|
||||
? ' сум'
|
||||
: locale == LanguageRoutes.KI
|
||||
: locale === LanguageRoutes.KI
|
||||
? ' сўм'
|
||||
: ' so‘m'
|
||||
: '';
|
||||
@@ -22,7 +22,7 @@ const formatPrice = async (amount: number | string, withLabel?: boolean) => {
|
||||
|
||||
const formattedDollars = dollars.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
|
||||
if (String(amount).length == 0) {
|
||||
if (String(amount).length === 0) {
|
||||
return formattedDollars + '.' + cents + label;
|
||||
} else {
|
||||
return formattedDollars + label;
|
||||
|
||||
11
src/shared/ui/aspect-ratio.tsx
Normal file
11
src/shared/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio';
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
|
||||
}
|
||||
|
||||
export { AspectRatio };
|
||||
53
src/shared/ui/avatar.tsx
Normal file
53
src/shared/ui/avatar.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as AvatarPrimitive from '@radix-ui/react-avatar';
|
||||
|
||||
import { cn } from '@/shared/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
src/shared/ui/badge.tsx
Normal file
46
src/shared/ui/badge.tsx
Normal 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 '@/shared/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 };
|
||||
92
src/shared/ui/card.tsx
Normal file
92
src/shared/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/shared/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
src/shared/ui/carousel.tsx
Normal file
241
src/shared/ui/carousel.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
'use client';
|
||||
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from 'embla-carousel-react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { Button } from '@/shared/ui/button';
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
export 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);
|
||||
|
||||
export 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 -right-12 -translate-y-1/2'
|
||||
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className={cn(className, 'size-5')} />
|
||||
<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}
|
||||
>
|
||||
<ChevronRight className={cn(className, 'size-5')} />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
type CarouselApi,
|
||||
};
|
||||
24
src/shared/ui/label.tsx
Normal file
24
src/shared/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
|
||||
import { cn } from '@/shared/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 };
|
||||
48
src/shared/ui/popover.tsx
Normal file
48
src/shared/ui/popover.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
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 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger };
|
||||
31
src/shared/ui/progress.tsx
Normal file
31
src/shared/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Progress };
|
||||
31
src/shared/ui/switch.tsx
Normal file
31
src/shared/ui/switch.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
||||
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
className={cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className={cn(
|
||||
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
66
src/shared/ui/tabs.tsx
Normal file
66
src/shared/ui/tabs.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
|
||||
import { cn } from '@/shared/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(
|
||||
"data-[state=active]:bg-background 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 };
|
||||
18
src/shared/ui/textarea.tsx
Normal file
18
src/shared/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/shared/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 };
|
||||
148
src/widgets/categories/lib/data.ts
Normal file
148
src/widgets/categories/lib/data.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
export const categories: Product[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Elektronika',
|
||||
products: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Krasovka',
|
||||
price: 125000,
|
||||
oldPrice: 14000000,
|
||||
image: '/adidas-ultraboost-running-shoes.jpg',
|
||||
rating: 4.5,
|
||||
reviews: 128,
|
||||
discount: 10,
|
||||
inStock: true,
|
||||
liked: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Apple MacBook Pro 14"',
|
||||
price: 25000000,
|
||||
oldPrice: 27000000,
|
||||
image: '/apple-ipad-pro-tablet.jpg',
|
||||
rating: 5.0,
|
||||
reviews: 89,
|
||||
discount: 8,
|
||||
liked: false,
|
||||
inStock: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Apple MacBook Pro 14"',
|
||||
price: 25000000,
|
||||
oldPrice: 27000000,
|
||||
image: '/apple-ipad-pro-tablet.jpg',
|
||||
rating: 5.0,
|
||||
reviews: 89,
|
||||
discount: 8,
|
||||
inStock: true,
|
||||
liked: true,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Apple MacBook Pro 14"',
|
||||
price: 25000000,
|
||||
oldPrice: 27000000,
|
||||
image: '/apple-ipad-pro-tablet.jpg',
|
||||
rating: 5.0,
|
||||
reviews: 89,
|
||||
discount: 8,
|
||||
inStock: true,
|
||||
liked: false,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Sony WH-1000XM5',
|
||||
price: 3500000,
|
||||
oldPrice: 4000000,
|
||||
image: '/sony-wh-1000xm5.png',
|
||||
rating: 4.8,
|
||||
reviews: 256,
|
||||
discount: 12,
|
||||
inStock: true,
|
||||
liked: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Sport kiyimlari',
|
||||
products: [
|
||||
{
|
||||
id: 6,
|
||||
name: 'Nike Air Max 270',
|
||||
price: 1800000,
|
||||
oldPrice: 2000000,
|
||||
image: '/nike-air-max-270.png',
|
||||
rating: 4.3,
|
||||
reviews: 342,
|
||||
discount: 10,
|
||||
inStock: false,
|
||||
liked: false,
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Adidas Ultraboost',
|
||||
price: 2000000,
|
||||
oldPrice: 2200000,
|
||||
image: '/adidas-ultraboost-running-shoes.jpg',
|
||||
rating: 4.6,
|
||||
reviews: 215,
|
||||
discount: 9,
|
||||
liked: true,
|
||||
inStock: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Uy jihozlari',
|
||||
products: [
|
||||
{
|
||||
id: 8,
|
||||
name: 'LG OLED TV 55"',
|
||||
price: 18000000,
|
||||
oldPrice: 20000000,
|
||||
image: '/lg-oled-tv-55-inch.jpg',
|
||||
rating: 4.7,
|
||||
reviews: 76,
|
||||
discount: 10,
|
||||
inStock: true,
|
||||
liked: false,
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'Canon EOS R6',
|
||||
price: 22000000,
|
||||
oldPrice: 24000000,
|
||||
image: '/canon-eos-r6-camera.jpg',
|
||||
rating: 4.9,
|
||||
reviews: 54,
|
||||
discount: 8,
|
||||
inStock: true,
|
||||
liked: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
products: ProductDetail[];
|
||||
}
|
||||
|
||||
export interface ProductDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
categoryName?: string;
|
||||
price: number;
|
||||
oldPrice: number;
|
||||
image: string;
|
||||
rating: number;
|
||||
reviews: number;
|
||||
discount: number;
|
||||
inStock: boolean;
|
||||
liked: boolean;
|
||||
}
|
||||
74
src/widgets/categories/ui/category-carousel.tsx
Normal file
74
src/widgets/categories/ui/category-carousel.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { SubCategory } from '@/features/category/lib/data';
|
||||
import { useRouter } from '@/shared/config/i18n/navigation';
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from '@/shared/ui/carousel';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ProductCard } from './product-card';
|
||||
|
||||
export function CategoryCarousel({ category }: { category: SubCategory }) {
|
||||
const [products, setProducts] = useState(category.products);
|
||||
const router = useRouter();
|
||||
|
||||
const handleRemove = (id: number) => {
|
||||
setProducts((prev) =>
|
||||
prev.map((product) =>
|
||||
product.id === id ? { ...product, liked: false } : product,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleLiked = (id: number) => {
|
||||
setProducts((prev) =>
|
||||
prev.map((product) =>
|
||||
product.id === id ? { ...product, liked: true } : product,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative custom-container mt-8 justify-center items-center">
|
||||
<div className="flex items-center justify-between mb-6 pb-3 border-b border-slate-200">
|
||||
<div
|
||||
className="flex items-center gap-2 group cursor-pointer"
|
||||
onClick={() =>
|
||||
router.push(`/category/${category.category}/${category.name}`)
|
||||
}
|
||||
>
|
||||
<h2 className="text-2xl font-bold text-slate-800 group-hover:text-blue-600 transition-colors">
|
||||
{category.name}
|
||||
</h2>
|
||||
<div className="p-1.5 bg-slate-100 rounded-full group-hover:bg-blue-100 transition-all">
|
||||
<ChevronRight className="text-slate-600 group-hover:text-blue-600 group-hover:translate-x-0.5 transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Carousel className="w-full">
|
||||
<CarouselContent className="flex">
|
||||
{products.slice(0, 12).map((product) => (
|
||||
<CarouselItem
|
||||
key={product.id}
|
||||
className="basis-1/1 md:basis-1/2 lg:basis-1/4 pb-2"
|
||||
>
|
||||
<ProductCard
|
||||
product={product}
|
||||
handleRemove={handleRemove}
|
||||
handleLiked={handleLiked}
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<CarouselNext className="-top-12 right-0 rounded-lg w-10 h-10 max-lg:hidden bg-blue-600 hover:bg-blue-600 cursor-pointer text-white border-0" />
|
||||
<CarouselPrevious className="-top-12 right-12 rounded-lg w-10 h-10 max-lg:hidden bg-blue-600 hover:bg-blue-600 cursor-pointer text-white border-0" />
|
||||
</Carousel>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
166
src/widgets/categories/ui/product-card.tsx
Normal file
166
src/widgets/categories/ui/product-card.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from '@/shared/config/i18n/navigation';
|
||||
import formatPrice from '@/shared/lib/formatPrice';
|
||||
import { Button } from '@/shared/ui/button';
|
||||
import { Card, CardContent } from '@/shared/ui/card';
|
||||
import { Input } from '@/shared/ui/input';
|
||||
import { Heart, Minus, Plus, ShoppingCart, Star } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { MouseEvent, useState } from 'react';
|
||||
|
||||
interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
oldPrice: number;
|
||||
image: string;
|
||||
rating: number;
|
||||
reviews: number;
|
||||
discount: number;
|
||||
inStock: boolean;
|
||||
liked: boolean;
|
||||
}
|
||||
|
||||
export function ProductCard({
|
||||
product,
|
||||
handleRemove,
|
||||
handleLiked,
|
||||
}: {
|
||||
product: Product;
|
||||
handleRemove: (id: number) => void;
|
||||
handleLiked?: (id: number) => void;
|
||||
}) {
|
||||
const [quantity, setQuantity] = useState<number | ''>(0);
|
||||
const router = useRouter();
|
||||
|
||||
const increase = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
setQuantity((q) => (q === '' ? 1 : q + 1));
|
||||
};
|
||||
const decrease = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
setQuantity((q) => (q && q > 1 ? q - 1 : 0));
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
onClick={() => router.push(`/product/${product.id}`)}
|
||||
className="group relative p-0 overflow-hidden border border-slate-200 bg-white shadow-sm hover:shadow-xl transition-all duration-300 rounded-2xl hover:border-blue-400"
|
||||
>
|
||||
<CardContent className="!p-0">
|
||||
<div className="relative overflow-hidden">
|
||||
{product.discount > 0 && (
|
||||
<div className="absolute top-3 left-3 z-10 bg-orange-500 text-white px-2.5 py-1 rounded-full text-sm font-bold">
|
||||
-{product.discount}%
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (product.liked) {
|
||||
handleRemove(product.id);
|
||||
} else if (handleLiked && !product.liked) {
|
||||
handleLiked(product.id);
|
||||
}
|
||||
}}
|
||||
className="absolute hover:bg-white cursor-pointer top-3 right-3 z-10 bg-white rounded-full p-2 shadow-md hover:scale-110 transition-all duration-300"
|
||||
>
|
||||
<Heart
|
||||
className={`w-5 h-5 transition-colors ${product.liked ? 'fill-red-500 text-red-500' : 'text-slate-400 hover:text-red-400'}`}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<div className="relative h-96 bg-slate-50 overflow-hidden">
|
||||
<Image
|
||||
width={500}
|
||||
height={500}
|
||||
src={product.image || '/placeholder.svg'}
|
||||
alt={product.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 bg-orange-50 px-2 py-1 rounded-full">
|
||||
<Star className="w-4 h-4 fill-orange-400 text-orange-400" />
|
||||
<span className="text-sm font-semibold text-orange-600">
|
||||
{product.rating}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-slate-500 text-sm">
|
||||
({product.reviews} ta sharh)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-base text-slate-800 line-clamp-2 min-h-[3rem] leading-snug hover:text-blue-600 transition-colors">
|
||||
{product.name}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-blue-600">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
</div>
|
||||
{product.oldPrice && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-slate-400 line-through">
|
||||
{formatPrice(product.oldPrice)}
|
||||
</span>
|
||||
<span className="text-xs bg-orange-100 text-orange-600 px-2 py-0.5 rounded-full font-semibold">
|
||||
Tejang!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quantity === 0 ? (
|
||||
<Button
|
||||
disabled={!product.inStock}
|
||||
onClick={(e) => {
|
||||
setQuantity(1);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="w-full h-11 rounded-xl bg-blue-600 text-white"
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5 mr-2" />
|
||||
Savatga qo‘shish
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center justify-between border border-blue-500 rounded-xl h-11 px-2">
|
||||
<Button size="icon" variant="ghost" onClick={decrease}>
|
||||
<Minus />
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
value={quantity}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
|
||||
if (/^\d*$/.test(value)) {
|
||||
setQuantity(value === '' ? '' : Number(value));
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (quantity === '' || quantity < 1) {
|
||||
setQuantity(1);
|
||||
}
|
||||
}}
|
||||
className="w-full text-center outline-none ring-0 focus-visible:ring-0 border-none font-semibold"
|
||||
/>
|
||||
|
||||
<Button size="icon" variant="ghost" onClick={increase}>
|
||||
<Plus />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
const sections = [
|
||||
{
|
||||
title: 'Product',
|
||||
title: 'Kategoriyalar',
|
||||
links: [
|
||||
{ name: 'Overview', href: '#' },
|
||||
{ name: 'Pricing', href: '#' },
|
||||
|
||||
@@ -1,82 +1,114 @@
|
||||
import { PRODUCT_INFO } from '@/shared/constants/data';
|
||||
import { InstagramIcon, YoutubeIcon } from 'lucide-react';
|
||||
import { sections } from '../lib/data';
|
||||
import { ModeToggle } from '@/shared/ui/theme-toggle';
|
||||
import formatPhone from '@/shared/lib/formatPhone';
|
||||
import { categoryList } from '@/widgets/welcome/lib/data';
|
||||
import { Facebook, Instagram, Mail, Phone, Send, Twitter } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<section className="py-32">
|
||||
<div className="custom-container">
|
||||
<div className="flex w-full flex-col items-center justify-between gap-10 text-center lg:flex-row lg:items-start lg:text-left">
|
||||
<div className="flex w-full flex-col items-center justify-between gap-6 lg:items-start">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 lg:justify-start">
|
||||
<a href="https://shadcnblocks.com">
|
||||
<img
|
||||
src={PRODUCT_INFO.logo}
|
||||
<section className="max-lg:py-9 py-12 z-50 w-full bg-slate-50 border-t border-slate-200">
|
||||
<div className="custom-container max-lg:hidden">
|
||||
<div className="flex w-full gap-10 flex-col items-center justify-between text-center lg:flex-row lg:items-start lg:text-left">
|
||||
<div className="flex w-fit flex-col items-center justify-between gap-4 lg:items-start mb-8 lg:mb-0">
|
||||
<div className="flex items-center gap-3 lg:justify-start">
|
||||
<div className="p-2 bg-blue-600 rounded-xl">
|
||||
<Image
|
||||
width={500}
|
||||
height={500}
|
||||
src={PRODUCT_INFO.logo || '/placeholder.svg'}
|
||||
alt={PRODUCT_INFO.name}
|
||||
title={PRODUCT_INFO.name}
|
||||
className="h-8"
|
||||
className="h-6 w-6 brightness-0 invert"
|
||||
/>
|
||||
</a>
|
||||
<h2 className="text-xl font-semibold">{PRODUCT_INFO.name}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A collection of 100+ responsive HTML templates for your startup
|
||||
business or side project.
|
||||
</p>
|
||||
<ul className="flex items-center space-x-6 text-muted-foreground">
|
||||
<li className="font-medium hover:text-primary">
|
||||
<a href="#">
|
||||
<InstagramIcon className="size-6" />
|
||||
</a>
|
||||
</li>
|
||||
<li className="font-medium hover:text-primary">
|
||||
<a href="#">
|
||||
<YoutubeIcon className="size-6" />
|
||||
</a>
|
||||
</li>
|
||||
<li className="font-medium hover:text-primary">
|
||||
<a href="#">
|
||||
<InstagramIcon className="size-6" />
|
||||
</a>
|
||||
</li>
|
||||
<li className="font-medium hover:text-primary">
|
||||
<a href="#">
|
||||
<InstagramIcon className="size-6" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-3 gap-6 lg:gap-20">
|
||||
{sections.map((section, sectionIdx) => (
|
||||
<div key={sectionIdx}>
|
||||
<h3 className="mb-6 font-bold">{section.title}</h3>
|
||||
<ul className="space-y-4 text-sm text-muted-foreground">
|
||||
{section.links.map((link, linkIdx) => (
|
||||
<li
|
||||
key={linkIdx}
|
||||
className="font-medium hover:text-primary"
|
||||
>
|
||||
<a href={link.href}>{link.name}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
<h2 className="text-xl font-bold text-slate-800">
|
||||
{PRODUCT_INFO.name}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-slate-600 max-w-xs leading-relaxed text-sm">
|
||||
Lorem ipsum dolor sit amet consectetur, adipisicing elit. Est,
|
||||
totam?
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid w-full grid-cols-1 md:grid-cols-3 gap-8 lg:gap-16">
|
||||
<div>
|
||||
<h3 className="mb-4 font-bold text-base text-black">
|
||||
Kategoriyalar
|
||||
</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{categoryList.slice(0, 3).map((link) => (
|
||||
<Fragment key={link.name}>
|
||||
{link.subCategories.slice(0, 2).map((e, linkIdx) => (
|
||||
<li
|
||||
key={linkIdx}
|
||||
className="text-muted-foreground hover:text-blue-600 transition-colors cursor-pointer"
|
||||
>
|
||||
<a href={`/category/${link.name}/${e.name}`}>
|
||||
{e.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</Fragment>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 font-bold text-base text-black">Sahifalar</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="text-muted-foreground hover:text-blue-600 transition-colors cursor-pointer">
|
||||
<a href={'#'}>Biz haqimizda</a>
|
||||
</li>
|
||||
<li className="text-muted-foreground hover:text-blue-600 transition-colors cursor-pointer">
|
||||
<a href={'#'}>Mahfiylik siyosati</a>
|
||||
</li>
|
||||
<li className="text-muted-foreground hover:text-blue-600 transition-colors cursor-pointer">
|
||||
<a href={'#'}>Savol va javoblar</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-4 font-bold text-base text-black">Aloqa</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="text-muted-foreground hover:text-blue-600 transition-colors cursor-pointer">
|
||||
<a href={'#'} className="flex items-center gap-2">
|
||||
<Send className="size-4" />
|
||||
<p>Telegram</p>
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-muted-foreground hover:text-blue-600 transition-colors cursor-pointer">
|
||||
<a href={'#'} className="flex items-center gap-2">
|
||||
<Instagram className="size-4" />
|
||||
<p>Instagram</p>
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-muted-foreground hover:text-blue-600 transition-colors cursor-pointer">
|
||||
<a href={'#'} className="flex items-center gap-2">
|
||||
<Facebook className="size-4" />
|
||||
<p>Facebook</p>
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-muted-foreground hover:text-blue-600 transition-colors cursor-pointer">
|
||||
<a href={'#'} className="flex items-center gap-2">
|
||||
<Twitter className="size-4" />
|
||||
<p>Twitter</p>
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-muted-foreground hover:text-blue-600 transition-colors cursor-pointer">
|
||||
<a href={'#'} className="flex items-center gap-2">
|
||||
<Mail className="size-4" />
|
||||
<p>e-mail</p>
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-muted-foreground hover:text-blue-600 transition-colors cursor-pointer">
|
||||
<a href={'#'} className="flex items-center gap-2">
|
||||
<Phone className="size-4" />
|
||||
<p>{formatPhone('+998901234567')}</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 flex flex-col justify-between gap-4 border-t pt-8 text-center text-sm font-medium text-muted-foreground lg:flex-row lg:items-center lg:text-left">
|
||||
<p>
|
||||
© {new Date().getFullYear()} {PRODUCT_INFO.creator}. All rights
|
||||
reserved.
|
||||
</p>
|
||||
<ul className="flex justify-center gap-4 lg:justify-start">
|
||||
<li className="hover:text-primary">
|
||||
<a href={PRODUCT_INFO.terms_of_use}>Terms and Conditions</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LanguageRoutes } from '@/shared/config/i18n/types';
|
||||
import { Book, Sunset, Trees, Zap } from 'lucide-react';
|
||||
import { MenuItem } from './model';
|
||||
import { LanguageRoutes } from '@/shared/config/i18n/types';
|
||||
|
||||
const menu: MenuItem[] = [
|
||||
{ title: 'Home', url: '#' },
|
||||
@@ -80,14 +80,14 @@ const languages: { name: string; key: LanguageRoutes }[] = [
|
||||
name: "O'zbekcha",
|
||||
key: LanguageRoutes.UZ,
|
||||
},
|
||||
{
|
||||
name: 'Ўзбекча',
|
||||
key: LanguageRoutes.KI,
|
||||
},
|
||||
// {
|
||||
// name: 'Ўзбекча',
|
||||
// key: LanguageRoutes.KI,
|
||||
// },
|
||||
{
|
||||
name: 'Русский',
|
||||
key: LanguageRoutes.RU,
|
||||
},
|
||||
];
|
||||
|
||||
export { menu, languages };
|
||||
export { languages, menu };
|
||||
|
||||
23
src/widgets/navbar/lib/openCategory.ts
Normal file
23
src/widgets/navbar/lib/openCategory.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { CategoryType } from '@/widgets/welcome/lib/data';
|
||||
import { create } from 'zustand';
|
||||
|
||||
type Category = {
|
||||
active: CategoryType | null;
|
||||
openToolbar: boolean;
|
||||
};
|
||||
|
||||
type Actions = {
|
||||
setActive: (active: CategoryType | null) => void;
|
||||
setOpenToolbar: (openToolbar: boolean) => void;
|
||||
setCloseToolbar: (openToolbar: boolean) => void;
|
||||
};
|
||||
|
||||
const useCategoryActive = create<Category & Actions>((set) => ({
|
||||
active: null,
|
||||
openToolbar: false,
|
||||
setActive: (active: CategoryType | null) => set(() => ({ active })),
|
||||
setOpenToolbar: () => set(() => ({ openToolbar: true })),
|
||||
setCloseToolbar: () => set(() => ({ openToolbar: false })),
|
||||
}));
|
||||
|
||||
export default useCategoryActive;
|
||||
@@ -1,17 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { GlobeIcon } from 'lucide-react';
|
||||
import type { LanguageRoutes } from '@/shared/config/i18n/types';
|
||||
import { Button } from '@/shared/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/shared/ui/dropdown-menu';
|
||||
import { Button } from '@/shared/ui/button';
|
||||
import { languages } from '../lib/data';
|
||||
import Image from 'next/image';
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation';
|
||||
import { LanguageRoutes } from '@/shared/config/i18n/types';
|
||||
import { languages } from '../lib/data';
|
||||
|
||||
export function ChangeLang() {
|
||||
const { locale } = useParams();
|
||||
@@ -27,15 +26,45 @@ export function ChangeLang() {
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<GlobeIcon />
|
||||
<span>{languages.find((e) => e.key == locale)?.name}</span>
|
||||
<DropdownMenuTrigger asChild className="h-7">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="bg-white/20 hover:bg-white/30 backdrop-blur-sm rounded-sm px-3"
|
||||
>
|
||||
<div className="w-5 h-5 rounded-full overflow-hidden border border-white/50 mr-2">
|
||||
{languages.find((e) => e.key === locale)?.key === 'uz' ? (
|
||||
<Image
|
||||
src="/flags/uz.png"
|
||||
alt="uz"
|
||||
width={20}
|
||||
height={20}
|
||||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src="/flags/ru.png"
|
||||
alt="ru"
|
||||
width={20}
|
||||
height={20}
|
||||
className="object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-white font-medium text-sm">
|
||||
{languages.find((e) => e.key === locale)?.name}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-white border border-slate-200 shadow-lg rounded-lg"
|
||||
>
|
||||
{languages.map((e, i) => (
|
||||
<DropdownMenuItem key={i} onClick={() => changeLocale(e.key)}>
|
||||
<DropdownMenuItem
|
||||
key={i}
|
||||
onClick={() => changeLocale(e.key)}
|
||||
className="hover:bg-blue-50 cursor-pointer text-slate-700 hover:text-blue-700 px-3 py-2"
|
||||
>
|
||||
{e.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
78
src/widgets/navbar/ui/MobileLanguageSelector.tsx
Normal file
78
src/widgets/navbar/ui/MobileLanguageSelector.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from '@/shared/config/i18n/navigation';
|
||||
import { LanguageRoutes } from '@/shared/config/i18n/types';
|
||||
import { Button } from '@/shared/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/shared/ui/dropdown-menu';
|
||||
import Image from 'next/image';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { languages } from '../lib/data';
|
||||
|
||||
export const MobileLanguageSelector = () => {
|
||||
const { locale } = useParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const changeLocale = (newLocale: LanguageRoutes) => {
|
||||
const segments = pathname.split('/');
|
||||
segments[1] = newLocale;
|
||||
const newPath = segments.join('/');
|
||||
router.push(newPath);
|
||||
};
|
||||
|
||||
const currentLanguage = languages.find((e) => e.key === locale);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-lg hover:bg-slate-100"
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full overflow-hidden border-2 border-slate-200">
|
||||
<Image
|
||||
src={
|
||||
currentLanguage?.key === 'uz'
|
||||
? '/flags/uz.png'
|
||||
: '/flags/ru.png'
|
||||
}
|
||||
alt={currentLanguage?.key || 'language'}
|
||||
width={24}
|
||||
height={24}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-white border border-slate-200 shadow-lg rounded-lg min-w-[120px]"
|
||||
>
|
||||
{languages.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang.key}
|
||||
onClick={() => changeLocale(lang.key)}
|
||||
className="hover:bg-blue-50 cursor-pointer text-slate-700 hover:text-blue-700 px-3 py-2 flex items-center gap-2"
|
||||
>
|
||||
<div className="w-5 h-5 rounded-full overflow-hidden border border-slate-200">
|
||||
<Image
|
||||
src={lang.key === 'uz' ? '/flags/uz.png' : '/flags/ru.png'}
|
||||
alt={lang.key}
|
||||
width={20}
|
||||
height={20}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium">{lang.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
72
src/widgets/navbar/ui/NavbarMobile.tsx
Normal file
72
src/widgets/navbar/ui/NavbarMobile.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/shared/lib/utils';
|
||||
import { Button } from '@/shared/ui/button';
|
||||
import { Heart, Home, LayoutGrid, ShoppingCart, User } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const NavbarMobile = () => {
|
||||
const pathname = usePathname();
|
||||
const [profile, setProfile] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const user = localStorage.getItem('user');
|
||||
setProfile(user);
|
||||
}, []);
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Asosiy', icon: Home, href: '/' },
|
||||
{ label: 'Katalog', icon: LayoutGrid, href: '/category' },
|
||||
{ label: 'Sevimli', icon: Heart, href: '/favourite' },
|
||||
{ label: 'Savatda', icon: ShoppingCart, href: '/cart' },
|
||||
{
|
||||
label: 'Profil',
|
||||
icon: User,
|
||||
href: profile === 'true' ? '/profile' : '/auth',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 lg:hidden bg-white border-t shadow-xl rounded-t-3xl">
|
||||
<div className="grid grid-cols-5 h-14 px-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
item.href === '/'
|
||||
? pathname === '/' || pathname === `/${pathname.split('/')[1]}`
|
||||
: pathname.includes(item.href);
|
||||
|
||||
return (
|
||||
<Link key={item.href} href={item.href}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'h-full w-full flex flex-col items-center justify-center gap-1 rounded-xl',
|
||||
isActive && 'text-blue-500',
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn(
|
||||
'size-6 transition-colors',
|
||||
isActive ? 'text-blue-500' : 'text-gray-500',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium',
|
||||
isActive ? 'text-blue-500' : 'text-gray-500',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavbarMobile;
|
||||
66
src/widgets/navbar/ui/SearchResult.tsx
Normal file
66
src/widgets/navbar/ui/SearchResult.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import formatPrice from '@/shared/lib/formatPrice';
|
||||
import { categories, ProductDetail } from '@/widgets/categories/lib/data';
|
||||
import { PackageOpen } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
|
||||
type SearchResultProps = {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export const SearchResult = ({ query }: SearchResultProps) => {
|
||||
const [searchProduct, setSearchProduct] = useState<ProductDetail[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchProduct(
|
||||
categories.flatMap((cat) =>
|
||||
cat.products.filter((pro) =>
|
||||
pro.name.toLowerCase().includes(query.toLowerCase()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
if (searchProduct.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center min-h-[300px] max-h-[600px] gap-2">
|
||||
<PackageOpen className="size-22 text-muted-foreground" />
|
||||
<p className="text-lg text-muted-foreground text-center">
|
||||
Hech narsa topilmadi
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{query.length > 0 ? 'Qidiruv natijalari' : 'Tavsiya etiladi'}
|
||||
</p>
|
||||
|
||||
{searchProduct.slice(0, 5).map((product, index) => (
|
||||
<Fragment key={index}>
|
||||
<div className="flex items-center gap-3 p-2 rounded-lg hover:bg-slate-100 cursor-pointer transition">
|
||||
<Image
|
||||
width={500}
|
||||
height={500}
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="w-10 h-10 rounded-md object-cover"
|
||||
/>
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-800">
|
||||
{product.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">{product.rating}</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
{formatPrice(product.price)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,122 +1,324 @@
|
||||
import { Accordion } from '@/shared/ui/accordion';
|
||||
'use client';
|
||||
|
||||
import formatPhone from '@/shared/lib/formatPhone';
|
||||
import { Button } from '@/shared/ui/button';
|
||||
import { Input } from '@/shared/ui/input';
|
||||
import { Popover, PopoverContent } from '@/shared/ui/popover';
|
||||
import { categoryList } from '@/widgets/welcome/lib/data';
|
||||
import { PopoverTrigger } from '@radix-ui/react-popover';
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
} from '@/shared/ui/navigation-menu';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/shared/ui/sheet';
|
||||
import { Menu } from 'lucide-react';
|
||||
import { menu } from '../lib/data';
|
||||
import { PRODUCT_INFO } from '@/shared/constants/data';
|
||||
import RenderMenuItem from './RenderItem';
|
||||
import RenderMobileMenuItem from './RenderMobileMenuItem';
|
||||
ChevronRight,
|
||||
Facebook,
|
||||
Heart,
|
||||
Instagram,
|
||||
LayoutGrid,
|
||||
Mail,
|
||||
Phone,
|
||||
Search,
|
||||
Send,
|
||||
ShoppingCart,
|
||||
Twitter,
|
||||
User,
|
||||
XIcon,
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useCategoryActive from '../lib/openCategory';
|
||||
import { ChangeLang } from './ChangeLang';
|
||||
import Link from 'next/link';
|
||||
import { MobileLanguageSelector } from './MobileLanguageSelector';
|
||||
import NavbarMobile from './NavbarMobile';
|
||||
import { SearchResult } from './SearchResult';
|
||||
|
||||
const Navbar = () => {
|
||||
const auth = {
|
||||
login: { title: 'Login', url: '#' },
|
||||
signup: { title: 'Sign up', url: '#' },
|
||||
};
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const queryFromUrl = searchParams.get('q') ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(queryFromUrl);
|
||||
}, [queryFromUrl]);
|
||||
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const { active, openToolbar, setActive, setOpenToolbar, setCloseToolbar } =
|
||||
useCategoryActive();
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsSticky(window.scrollY > 40);
|
||||
};
|
||||
|
||||
handleScroll();
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<section className="py-4">
|
||||
<div className="custom-container">
|
||||
{/* Desktop Menu */}
|
||||
<nav className="hidden justify-between lg:flex">
|
||||
<div className="flex items-center gap-6">
|
||||
{/* Logo */}
|
||||
<Link href={'/'} className="flex items-center gap-2">
|
||||
<img
|
||||
src={PRODUCT_INFO.logo}
|
||||
className="max-h-8"
|
||||
alt={PRODUCT_INFO.name}
|
||||
/>
|
||||
<span className="text-lg font-semibold tracking-tighter">
|
||||
{PRODUCT_INFO.name}
|
||||
</span>
|
||||
</Link>
|
||||
<div className="flex items-center">
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList>
|
||||
{menu.map((item) => RenderMenuItem(item))}
|
||||
</NavigationMenuList>
|
||||
</NavigationMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<ChangeLang />
|
||||
<Button asChild variant="outline">
|
||||
<Link href={auth.login.url}>{auth.login.title}</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={auth.signup.url}>{auth.signup.title}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<div className="block lg:hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link href={'/'} className="flex items-center gap-2">
|
||||
<img
|
||||
src={PRODUCT_INFO.logo}
|
||||
className="max-h-8"
|
||||
alt={PRODUCT_INFO.name}
|
||||
/>
|
||||
</Link>
|
||||
<Sheet>
|
||||
<div className="space-x-2">
|
||||
<ChangeLang />
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Menu className="size-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
</div>
|
||||
<SheetContent className="overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
<Link href={'/'} className="flex items-center gap-2">
|
||||
<img
|
||||
src={PRODUCT_INFO.logo}
|
||||
className="max-h-8"
|
||||
alt={PRODUCT_INFO.name}
|
||||
/>
|
||||
</Link>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="flex w-full flex-col gap-4"
|
||||
>
|
||||
{menu.map((item) => RenderMobileMenuItem(item))}
|
||||
</Accordion>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button asChild variant="outline">
|
||||
<Link href={auth.login.url}>{auth.login.title}</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href={auth.signup.url}>{auth.signup.title}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
<>
|
||||
<div className="w-full bg-blue-600 h-10 max-lg:hidden">
|
||||
<div className="custom-container h-full flex justify-between items-center">
|
||||
<ul className="text-sm flex items-center justify-center gap-4">
|
||||
<li className="text-white transition-colors cursor-pointer">
|
||||
<a href={'#'} className="flex items-center gap-2">
|
||||
<Send className="size-4" />
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-white transition-colors cursor-pointer">
|
||||
<a href={'#'} className="flex items-center gap-2">
|
||||
<Instagram className="size-4" />
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-white transition-colors cursor-pointer">
|
||||
<a href={'#'} className="flex items-center gap-2">
|
||||
<Facebook className="size-4" />
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-white transition-colors cursor-pointer">
|
||||
<a href={'#'} className="flex items-center gap-2">
|
||||
<Twitter className="size-4" />
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-white transition-colors cursor-pointer">
|
||||
<a href={'#'} className="flex items-center gap-2">
|
||||
<Mail className="size-4" />
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-white transition-colors cursor-pointer">
|
||||
<a href={'#'} className="flex items-center gap-2">
|
||||
<Phone className="size-4" />
|
||||
<p>{formatPhone('+998901234567')}</p>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<ChangeLang />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={`py-4 shadow-sm z-50 w-full bg-white transition-all duration-300 mb-5 border-b border-slate-200
|
||||
${isSticky ? 'fixed top-0 shadow-md' : 'relative'}
|
||||
`}
|
||||
>
|
||||
<div className="custom-container h-6 flex justify-between items-center gap-3">
|
||||
<div
|
||||
className="p-2 flex gap-2 rounded-xl cursor-pointer"
|
||||
onClick={() => router.push('/')}
|
||||
>
|
||||
<Image
|
||||
src="/favicon.png"
|
||||
alt="logo"
|
||||
width={40}
|
||||
height={20}
|
||||
className="w-6 h-6"
|
||||
/>
|
||||
<h3>Fias</h3>
|
||||
</div>
|
||||
<div className="w-full flex justify-end lg:hidden gap-2">
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
size={'icon'}
|
||||
onClick={() => router.push('/search')}
|
||||
>
|
||||
<Search className="size-5" />
|
||||
</Button>
|
||||
<MobileLanguageSelector />
|
||||
</div>
|
||||
<div className="flex-1 flex gap-3">
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className="h-10 max-lg:hidden cursor-pointer"
|
||||
onClick={() => {
|
||||
if (openToolbar) {
|
||||
setCloseToolbar(false);
|
||||
} else if (!openToolbar) {
|
||||
setOpenToolbar(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{openToolbar ? (
|
||||
<XIcon className="text-foreground" />
|
||||
) : (
|
||||
<LayoutGrid className="size-4 text-foreground" />
|
||||
)}
|
||||
<p className="text-foreground">Kataloglar</p>
|
||||
</Button>
|
||||
|
||||
<div className="relative w-full max-lg:hidden">
|
||||
<Input
|
||||
placeholder="Mahsulot nomi"
|
||||
value={query}
|
||||
onFocus={() => setSearchOpen(true)}
|
||||
onBlur={() => setTimeout(() => setSearchOpen(false), 200)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && query.trim()) {
|
||||
router.push(`/search?q=${encodeURIComponent(query)}`);
|
||||
setSearchOpen(false);
|
||||
}
|
||||
}}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="border border-slate-200 focus:border-blue-400 focus:ring-blue-400 px-9 h-10"
|
||||
/>
|
||||
|
||||
<Search className="absolute top-1/2 -translate-y-1/2 left-2 size-5 text-muted-foreground" />
|
||||
|
||||
{/* Search Results Dropdown */}
|
||||
{searchOpen && (
|
||||
<div className="absolute top-full left-0 right-0 mt-2 bg-white border border-slate-200 rounded-xl shadow-lg min-h-[300px] max-h-[600px] overflow-y-auto scrollbar-thin z-50">
|
||||
<div className="p-4">
|
||||
<SearchResult query={query} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
className="h-10 max-lg:hidden cursor-pointer border border-slate-200"
|
||||
onClick={() => router.push('/favourite')}
|
||||
>
|
||||
<Heart className="size-4 text-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
onClick={() => router.push('/cart')}
|
||||
className="h-10 max-lg:hidden cursor-pointer border border-slate-200"
|
||||
>
|
||||
<ShoppingCart className="size-4 text-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
onClick={() => {
|
||||
const user = localStorage.getItem('user');
|
||||
if (user === 'true') {
|
||||
router.push('/profile');
|
||||
} else {
|
||||
router.push('/auth');
|
||||
}
|
||||
}}
|
||||
className="h-10 max-lg:hidden cursor-pointer border border-slate-200"
|
||||
>
|
||||
<User className="size-4 text-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NavbarMobile />
|
||||
|
||||
<Popover open={openToolbar} modal={false}>
|
||||
<PopoverTrigger className="!h-0 absolute top-12 w-screen">
|
||||
<div />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[100vw] !p-0 h-[100vh] rounded-b-xl border-t-2 border-blue-500"
|
||||
onInteractOutside={() => setOpenToolbar(false)}
|
||||
>
|
||||
<div className="flex h-[90vh]">
|
||||
<div className="border-r border-slate-200 w-[20%] py-3 flex flex-col gap-2 px-3 overflow-y-auto scrollbar-thin bg-slate-50">
|
||||
{categoryList.map((e, index) => {
|
||||
const isActive = active?.name === e.name;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant="secondary"
|
||||
onMouseEnter={() => setActive(e)}
|
||||
onClick={() => setActive(e)}
|
||||
className={`flex justify-between items-center cursor-pointer px-4 h-14 rounded-xl transition-all duration-300 ${
|
||||
isActive
|
||||
? 'bg-blue-100 border border-blue-400'
|
||||
: 'hover:bg-slate-100 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div
|
||||
className={`p-1.5 rounded-lg ${isActive ? 'bg-white' : 'bg-white'}`}
|
||||
>
|
||||
<Image
|
||||
src={e.image || '/placeholder.svg'}
|
||||
alt={e.name}
|
||||
className="w-5 h-5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={`text-sm font-medium ${isActive ? 'text-blue-700' : 'text-slate-700'}`}
|
||||
>
|
||||
{e.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ChevronRight
|
||||
className={`size-5 transition-transform duration-300 ${
|
||||
isActive
|
||||
? 'rotate-90 text-blue-600'
|
||||
: 'text-slate-400'
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="w-[80%] overflow-y-auto p-8 scrollbar-thin bg-white">
|
||||
<h3 className="text-2xl font-bold mb-6 text-blue-600">
|
||||
{active?.name}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{active?.subCategories.map((sub, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
setCloseToolbar(false);
|
||||
router.push(`/category/${active.name}/${sub.name}`);
|
||||
}}
|
||||
variant="outline"
|
||||
className="justify-start h-12 cursor-pointer border border-slate-200 hover:border-blue-400 hover:bg-blue-50 transition-all text-slate-700 hover:text-blue-700 font-medium rounded-xl bg-transparent"
|
||||
>
|
||||
{sub.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</section>
|
||||
|
||||
{isSticky && <div className="h-[72px]" />}
|
||||
|
||||
<style jsx global>{`
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 #f1f5f9;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { getPosts } from '@/shared/config/api/testApi';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
|
||||
const Welcome = () => {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: () => getPosts({ _limit: 1 }),
|
||||
});
|
||||
|
||||
console.log('CSR posts', data);
|
||||
|
||||
return (
|
||||
<div className="custom-container h-full bg-accent min-h-[400px] rounded-2xl flex items-center justify-center">
|
||||
<Link
|
||||
className="github-button"
|
||||
href="https://github.com/fiasuz/create-fias"
|
||||
data-color-scheme="no-preference: light; light: light; dark: dark;"
|
||||
data-icon="octicon-star"
|
||||
data-size="large"
|
||||
aria-label="Star fiasuz/create-fias on GitHub"
|
||||
>
|
||||
Star on github
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Welcome;
|
||||
76
src/widgets/welcome/lib/data.ts
Normal file
76
src/widgets/welcome/lib/data.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import Category from '@/assets/water-bottle.png';
|
||||
import { StaticImageData } from 'next/image';
|
||||
|
||||
export const categoryList = [
|
||||
{
|
||||
name: 'Ichimliklar',
|
||||
image: Category,
|
||||
subCategories: [
|
||||
{ name: 'Suvlar' },
|
||||
{ name: 'Gazlangan ichimliklar' },
|
||||
{ name: 'Sharbatlar' },
|
||||
{ name: 'Energetik ichimliklar' },
|
||||
{ name: 'Choy va qahva' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Shirinliklar',
|
||||
image: Category,
|
||||
subCategories: [
|
||||
{ name: 'Shokoladlar' },
|
||||
{ name: 'Konfetlar' },
|
||||
{ name: 'Pechene va vafli' },
|
||||
{ name: 'Tortlar' },
|
||||
{ name: 'Murabbo va asal' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Moylar',
|
||||
image: Category,
|
||||
subCategories: [
|
||||
{ name: 'Kungaboqar yog‘i' },
|
||||
{ name: 'Zaytun yog‘i' },
|
||||
{ name: 'Sariyog‘' },
|
||||
{ name: 'Margarin' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Non mahsulotlari',
|
||||
image: Category,
|
||||
subCategories: [
|
||||
{ name: 'Oq non' },
|
||||
{ name: 'Qora non' },
|
||||
{ name: 'Bulochkalar' },
|
||||
{ name: 'Lavash va pita' },
|
||||
{ name: 'Kekslar' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Go‘sht',
|
||||
image: Category,
|
||||
subCategories: [
|
||||
{ name: 'Mol go‘shti' },
|
||||
{ name: 'Qo‘y go‘shti' },
|
||||
{ name: 'Tovuq go‘shti' },
|
||||
{ name: 'Kurka go‘shti' },
|
||||
{ name: 'Qiyma mahsulotlar' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Boshqa',
|
||||
image: Category,
|
||||
subCategories: [
|
||||
{ name: 'Ziravorlar' },
|
||||
{ name: 'Konserva mahsulotlari' },
|
||||
{ name: 'Soslar' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export interface CategoryType {
|
||||
name: string;
|
||||
image: StaticImageData;
|
||||
subCategories: {
|
||||
name: string;
|
||||
}[];
|
||||
}
|
||||
66
src/widgets/welcome/ui/index.tsx
Normal file
66
src/widgets/welcome/ui/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import Banner from '@/assets/banner.webp';
|
||||
import { AspectRatio } from '@/shared/ui/aspect-ratio';
|
||||
import useCategoryActive from '@/widgets/navbar/lib/openCategory';
|
||||
import Image from 'next/image';
|
||||
import 'swiper/css';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { categoryList } from '../lib/data';
|
||||
|
||||
const Welcome = () => {
|
||||
const { setActive, setOpenToolbar } = useCategoryActive();
|
||||
|
||||
return (
|
||||
<div className="custom-container">
|
||||
{/* Banner */}
|
||||
<AspectRatio ratio={16 / 6} className="!p-0">
|
||||
<div className="relative w-full h-full">
|
||||
<Image
|
||||
src={Banner || '/placeholder.svg'}
|
||||
alt="Banner"
|
||||
fill
|
||||
className="rounded-2xl object-cover shadow-lg border border-slate-200"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</AspectRatio>
|
||||
|
||||
{/* Category Slider */}
|
||||
<div className="mx-auto mt-5 max-lg:hidden">
|
||||
<Swiper
|
||||
spaceBetween={4}
|
||||
slidesPerView={4}
|
||||
breakpoints={{
|
||||
320: { slidesPerView: 1 },
|
||||
640: { slidesPerView: 2 },
|
||||
1024: { slidesPerView: 4 },
|
||||
}}
|
||||
>
|
||||
{categoryList.map((item, index) => (
|
||||
<SwiperSlide key={index} className="py-3 px-1">
|
||||
<div
|
||||
className="flex gap-1 items-center justify-center bg-gray-100/60 p-3 rounded-lg shadow-sm cursor-pointer space-x-3"
|
||||
onClick={() => {
|
||||
setOpenToolbar(true);
|
||||
setActive(item);
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={item.image}
|
||||
alt={item.name}
|
||||
className="w-7 h-7 object-contain"
|
||||
/>
|
||||
<p className="text-sm font-bold truncate line-clamp-2 leading-tight text-slate-700">
|
||||
{item.name}
|
||||
</p>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Welcome;
|
||||
Reference in New Issue
Block a user