first commit

This commit is contained in:
Jelaletdin12
2025-11-10 10:07:48 +05:00
commit fdec9e4b0e
131 changed files with 16660 additions and 0 deletions

167
components/ProductCard.tsx Normal file
View File

@@ -0,0 +1,167 @@
"use client";
import { useState, MouseEvent } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Heart, HeartOff, Minus, Plus } from "lucide-react";
import Image, { StaticImageData } from "next/image";
type ProductCardProps = {
id: number;
name: string;
price: number | null;
struct_price_text: string;
discount?: number | null;
discount_text?: string | null;
images: (StaticImageData | string)[];
is_favorite: boolean;
labels?: { text: string; bg_color: string }[];
price_color?: string;
height?: number;
width?: number;
button?: boolean;
};
export default function ProductCard({
id,
name,
price,
struct_price_text,
discount,
discount_text,
images,
is_favorite,
labels = [],
price_color = "#005bff",
height = 360,
width = 280,
button = true,
}: ProductCardProps) {
const [favorite, setFavorite] = useState(is_favorite);
const [cart, setCart] = useState(false);
const [count, setCount] = useState(1);
const handleFavorite = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setFavorite((prev) => !prev);
};
const handleAddToCart = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setCart(true);
};
const handleIncrement = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setCount((c) => c + 1);
};
const handleDecrement = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
e.stopPropagation();
setCount((c) => (c > 1 ? c - 1 : c));
};
return (
<Link href={`/product/${id}`} className="no-underline">
<Card
className={`relative gap-2 border-none shadow-none! p-0 w-full max-w-[${width}px] overflow-hidden rounded-2xl hover:shadow-md transition-all cursor-pointer`}
style={{ height }}
>
{/* Image Section */}
<div className="relative w-full h-[260px] ">
{images?.[0] && (
<Image
src={images[0]}
alt={name}
fill
sizes="(max-width: 600px) 100vw, 33vw"
className="object-contain "
priority
/>
)}
{/* Favorite Button */}
<button
onClick={handleFavorite}
className="absolute top-3 right-3 z-10 rounded-full bg-white/80 p-2 hover:bg-white"
>
{favorite ? (
<Heart className="w-5 h-5 text-red-500 fill-red-500" />
) : (
<Heart className="w-5 h-5 text-gray-700" />
)}
</button>
{/* Labels */}
{labels?.length > 0 && (
<div className="absolute bottom-2 left-2 flex flex-col gap-1">
{labels.map((label) => (
<Badge
key={label.text}
className="text-white text-[10px] font-bold uppercase rounded-r-md"
style={{ backgroundColor: label.bg_color }}
>
{label.text}
</Badge>
))}
</div>
)}
</div>
{/* Content */}
<CardContent className="p-0 space-y-1">
<p
className="text-sm font-semibold mx-2"
style={{ color: price_color }}
>
{struct_price_text}
</p>
<p className="text-gray-800 text-sm truncate mx-2">{name}</p>
</CardContent>
{/* Buttons */}
{/* {button && (
<div className="p-3">
{!cart ? (
<Button
className="w-full font-bold text-base rounded-xl"
onClick={handleAddToCart}
>
Заказать
</Button>
) : (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={handleDecrement}
disabled={count === 1}
className="rounded-xl"
>
<Minus className="w-4 h-4" />
</Button>
<div className="flex-1 text-center text-gray-700 border rounded-xl py-2">
{count}
</div>
<Button
variant="outline"
size="icon"
onClick={handleIncrement}
className="rounded-xl"
>
<Plus className="w-4 h-4" />
</Button>
</div>
)}
</div>
)} */}
</Card>
</Link>
);
}

View File

@@ -0,0 +1,32 @@
import { ShoppingCart } from "lucide-react"
import { Button } from "@/components/ui/button"
import Link from "next/link"
interface EmptyCartProps {
locale?: string
message?: string
actionText?: string
actionHref?: string
}
export default function EmptyCart({
locale = "ru",
message = "Your cart is empty",
actionText = "Start Shopping",
actionHref = "/",
}: EmptyCartProps) {
return (
<div className="min-h-[400px] flex flex-col items-center justify-center p-4">
<ShoppingCart className="h-16 w-16 text-gray-300 mb-4" />
<h2 className="text-2xl font-semibold text-gray-600 mb-2">{message}</h2>
<p className="text-gray-500 mb-6 text-center max-w-sm">
{locale === "ru"
? "Добавьте товары в корзину, чтобы начать покупки"
: "Add items to your cart to start shopping"}
</p>
<Link href={actionHref}>
<Button className="rounded-xl">{actionText}</Button>
</Link>
</div>
)
}

View File

@@ -0,0 +1,32 @@
import { Heart } from "lucide-react"
import { Button } from "@/components/ui/button"
import Link from "next/link"
interface EmptyFavoritesProps {
locale?: string
message?: string
actionText?: string
actionHref?: string
}
export default function EmptyFavorites({
locale = "ru",
message = "No favorite items yet",
actionText = "Browse Products",
actionHref = "/",
}: EmptyFavoritesProps) {
return (
<div className="min-h-[400px] flex flex-col items-center justify-center p-4">
<Heart className="h-16 w-16 text-gray-300 mb-4" />
<h2 className="text-2xl font-semibold text-gray-600 mb-2">{message}</h2>
<p className="text-gray-500 mb-6 text-center max-w-sm">
{locale === "ru"
? "Сохраняйте понравившиеся товары, чтобы найти их позже"
: "Save items you love to find them later"}
</p>
<Link href={actionHref}>
<Button className="rounded-xl">{actionText}</Button>
</Link>
</div>
)
}

View File

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

View File

@@ -0,0 +1,34 @@
import { Search } from "lucide-react"
import { Button } from "@/components/ui/button"
import Link from "next/link"
interface EmptySearchProps {
locale?: string
query?: string
message?: string
actionText?: string
actionHref?: string
}
export default function EmptySearch({
locale = "ru",
query = "",
message = "No results found",
actionText = "Back to Home",
actionHref = "/",
}: EmptySearchProps) {
return (
<div className="min-h-[400px] flex flex-col items-center justify-center p-4">
<Search className="h-16 w-16 text-gray-300 mb-4" />
<h2 className="text-2xl font-semibold text-gray-600 mb-2">{message}</h2>
{query && (
<p className="text-gray-500 mb-6 text-center max-w-sm">
{locale === "ru" ? `No products found for "${query}"` : `No products found for "${query}"`}
</p>
)}
<Link href={actionHref}>
<Button className="rounded-xl">{actionText}</Button>
</Link>
</div>
)
}

View File

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

View File

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

View File

@@ -0,0 +1,182 @@
"use client"
import { useLocale, useTranslations } from "next-intl"
import { useEffect, useState, useCallback } from "react"
import InfiniteScroll from "react-infinite-scroll-component"
import HeroCarousel from "./Carousel"
import CategoryGrid from "./CategoryGrid"
import CollectionSection from "./ProductGrid"
import { useCategories, useCarousels, useCollections } from "@/lib/hooks"
import type { Collection } from "@/lib/types/api"
export default function HomePage() {
const locale = useLocale()
const t = useTranslations("common")
const [mounted, setMounted] = useState(false)
const [visibleCollections, setVisibleCollections] = useState<Collection[]>([])
const [hasMore, setHasMore] = useState(true)
const itemsPerPage = 10
const { data: categories, isLoading: categoriesLoading, isError: categoriesError } = useCategories()
const { data: carousels, isLoading: carouselsLoading } = useCarousels()
const { data: collections, isLoading: collectionsLoading, isError: collectionsError } = useCollections()
useEffect(() => setMounted(true), [])
// Initialize visible collections when data first loads
useEffect(() => {
console.log("=== Collections Data Change ===")
console.log("Collections:", collections)
console.log("Collections length:", collections?.length)
console.log("Visible collections length:", visibleCollections.length)
if (collections && collections.length > 0 && visibleCollections.length === 0) {
console.log("🟢 Initializing first batch of collections")
const initial = collections.slice(0, itemsPerPage)
console.log("Initial collections to show:", initial.length)
setVisibleCollections(initial)
setHasMore(collections.length > itemsPerPage)
console.log("Has more after init:", collections.length > itemsPerPage)
}
}, [collections, visibleCollections.length])
const loadMoreCollections = useCallback(() => {
console.log("=== loadMoreCollections Called ===")
console.log("Collections available:", collections?.length)
console.log("Visible collections:", visibleCollections.length)
console.log("Has more:", hasMore)
if (!collections) {
console.log("❌ No collections data")
return
}
const currentLength = visibleCollections.length
const nextCollections = collections.slice(
currentLength,
currentLength + itemsPerPage
)
console.log("Current length:", currentLength)
console.log("Next batch size:", nextCollections.length)
console.log("Next batch:", nextCollections.map(c => c.id))
if (nextCollections.length > 0) {
console.log("🟢 Adding", nextCollections.length, "more collections")
setVisibleCollections((prev) => {
const updated = [...prev, ...nextCollections]
console.log("Updated visible collections count:", updated.length)
return updated
})
} else {
console.log("⚠️ No more collections to load")
}
// Check if we've loaded all collections
const newTotal = currentLength + nextCollections.length
const shouldHaveMore = newTotal < collections.length
console.log("New total:", newTotal, "/ Total available:", collections.length)
console.log("Should have more:", shouldHaveMore)
if (!shouldHaveMore) {
console.log("🔴 Setting hasMore to false")
setHasMore(false)
}
}, [collections, visibleCollections.length, itemsPerPage, hasMore])
useEffect(() => {
console.log("=== State Update ===")
console.log("Visible collections count:", visibleCollections.length)
console.log("Has more:", hasMore)
}, [visibleCollections.length, hasMore])
if (!mounted) return <div className="p-8">Loading...</div>
// Transform carousel data to match component props
const carouselItems = carousels?.map(carousel => ({
title: carousel.title || "",
image: carousel.image || carousel.thumbnail,
url: carousel.link || null
})) || []
console.log("=== Render ===")
console.log("Collections loading:", collectionsLoading)
console.log("Visible collections for render:", visibleCollections.length)
console.log("Has more for InfiniteScroll:", hasMore)
return (
<div className="px-4 md:px-8 lg:px-12 pt-8 pb-12 space-y-8">
{/* Hero Carousel with API data */}
{!carouselsLoading && carouselItems.length > 0 && (
<HeroCarousel items={carouselItems} />
)}
{/* Categories Grid */}
<CategoryGrid
categories={categories}
isLoading={categoriesLoading}
isError={categoriesError}
locale={locale}
title={t("categories")}
/>
{/* Collections Sections with Infinite Scroll */}
{collectionsError ? (
<section className="bg-white rounded-2xl shadow-sm p-6">
<p className="text-red-600">Failed to load collections. Please try again.</p>
</section>
) : collectionsLoading ? (
<div className="space-y-8">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="bg-white rounded-2xl shadow-sm p-6">
<div className="h-8 bg-gray-200 rounded w-48 mb-4 animate-pulse" />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, j) => (
<div key={j} className="h-64 bg-gray-200 rounded-lg animate-pulse" />
))}
</div>
</div>
))}
</div>
) : (
<>
<div className="bg-yellow-100 border border-yellow-400 rounded p-4 text-sm">
<strong>Debug Info:</strong><br/>
Total Collections: {collections?.length || 0}<br/>
Visible: {visibleCollections.length}<br/>
Has More: {hasMore ? "Yes" : "No"}
</div>
<InfiniteScroll
dataLength={visibleCollections.length}
next={loadMoreCollections}
hasMore={hasMore}
loader={
<div className="text-center py-8 bg-blue-50 border-2 border-blue-200 rounded">
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent"></div>
<p className="text-gray-500 mt-2 font-bold">Loading more collections...</p>
</div>
}
endMessage={
<div className="text-center py-8 bg-green-50 border-2 border-green-200 rounded">
<p className="text-gray-600 font-bold"> You've reached the end</p>
</div>
}
scrollThreshold={0.8}
>
<div className="space-y-8">
{visibleCollections.map((collection, index) => (
<div key={collection.id}>
<div className="text-xs text-gray-400 mb-2">Collection #{index + 1}</div>
<CollectionSection
collection={collection}
locale={locale}
/>
</div>
))}
</div>
</InfiniteScroll>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,112 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { ChevronRight } from "lucide-react"
import ProductCard from "@/components/ProductCard"
import { Skeleton } from "@/components/ui/skeleton"
import { useCollectionProducts } from "@/lib/hooks"
import type { Collection } from "@/lib/types/api"
type Props = {
collection: Collection
locale: string
}
export default function CollectionSection({ collection, locale }: Props) {
const router = useRouter()
const [shouldRender, setShouldRender] = useState(true)
const {
data: productsData,
isLoading,
isError
} = useCollectionProducts(collection.id, { enabled: shouldRender })
// Determine if section should render based on products
useEffect(() => {
if (!isLoading && productsData) {
const hasProducts = productsData.data && productsData.data.length > 0
setShouldRender(hasProducts)
}
}, [isLoading, productsData])
// Don't render if no products after loading
if (!isLoading && (!productsData?.data || productsData.data.length === 0)) {
return null
}
const handleTitleClick = () => {
router.push(`/${locale}/collections/${collection.id}`)
}
// Show skeleton while loading
if (isLoading) {
return (
<section className="bg-white rounded-2xl shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-6 w-6 rounded-full" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="w-full h-64 rounded-lg" />
))}
</div>
</section>
)
}
// Show error state
if (isError) {
return null // Silently skip errored collections
}
// Slice to show only first 4 products
const displayProducts = productsData?.data.slice(0, 4) || []
return (
<section className="bg-white rounded-2xl shadow-sm p-6">
<div
className="flex items-center justify-between mb-4 cursor-pointer group"
onClick={handleTitleClick}
>
<h2 className="text-xl font-semibold group-hover:text-blue-600 transition-colors">
{collection.name}
</h2>
<ChevronRight className="w-6 h-6 text-gray-400 group-hover:text-blue-600 group-hover:translate-x-1 transition-all" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{displayProducts.map((product) => {
// Extract first media image or use placeholder
const firstImage = product.media?.[0]?.images_800x800 ||
product.media?.[0]?.images_720x720 ||
product.media?.[0]?.thumbnail ||
"/placeholder-product.jpg"
// Format price
const formattedPrice = product.price_amount
? `${parseFloat(product.price_amount).toFixed(2)} TMT`
: "Price not available"
return (
<ProductCard
key={product.id}
id={product.id}
name={product.name}
price={product.price_amount ? parseFloat(product.price_amount) : null}
struct_price_text={formattedPrice}
images={[firstImage]}
is_favorite={false}
labels={[]}
price_color="#111"
height={360}
width={250}
button={false}
/>
)
})}
</div>
</section>
)
}

View File

@@ -0,0 +1,56 @@
import temp1 from "@/public/temp1.jpg"
import temp2 from "@/public/temp2.jpg"
import temp3 from "@/public/temp3.jpg"
import jbl from "@/public/jbl.png"
import jbll from "@/public/jbll.png"
import jbl3 from "@/public/jbl3.webp"
import jb from "@/public/jb.webp"
export const carouselItems = [
{ title: "Banner 1", image: temp1, url: "#" },
{ title: "Banner 2", image: temp2, url: "#" },
{ title: "Banner 3", image: temp3, url: "#" },
]
export const categories = [
{ id: 1, slug: "sneakers", name: "Sneakers", image: jbl },
{ id: 2, slug: "boots", name: "Boots", image: jbl3 },
{ id: 3, slug: "sandals", name: "Sandals", image: jbll },
{ id: 4, slug: "heels", name: "Heels", image: jb },
]
export const products = [
{
id: 1,
name: "Nike Air Max 270",
struct_price_text: "$120",
price: 120,
images: [jb, jbll, jbl, jbl3],
is_favorite: false,
labels: [{ text: "New", bg_color: "#10B981" }],
},
{
id: 2,
name: "Adidas Ultraboost",
struct_price_text: "$150",
price: 150,
images: [jbll, jb, jbl, jbl3],
is_favorite: true,
},
{
id: 3,
name: "Puma RS-X",
struct_price_text: "$110",
price: 110,
images: [jbl3, jbll, jbl, jb],
is_favorite: false,
},
{
id: 4,
name: "New Balance 327",
struct_price_text: "$130",
price: 130,
images: [jbl, jbll, jb, jbl3],
is_favorite: false,
},
]

View File

@@ -0,0 +1,153 @@
"use client"
import { useState, useEffect } from "react"
import Link from "next/link"
import Image from "next/image"
import { X, Menu, Search, Store } from "lucide-react"
import { Button } from "@/components/ui/button"
import Logo from "@/public/logo.png"
import CategoryMenu from "./ui/CategoryMenu"
import SearchBar from "./ui/SearchBar"
import AuthDialog from "./ui/AuthDialog"
import ActionButtons from "./ui/ActionButtons"
import LanguageSelector from "./ui/LanguageSelector"
interface HeaderProps {
locale?: string
isAuthenticated?: boolean
translations?: {
catalog: string
search: string
orders: string
favorites: string
cart: string
login: string
profile: string
openStore: string
phone: string
code: string
send: string
enterPhone: string
weWillSendCode: string
}
}
const DEFAULT_TRANSLATIONS = {
catalog: "Каталог",
search: "Поиск продукта",
orders: "Заказы",
favorites: "Избранное",
cart: "Корзина",
login: "Войти",
profile: "Профиль",
openStore: "Открыть магазин",
phone: "Номер телефона",
code: "Код",
send: "Отправить",
enterPhone: "Введите свой номер телефона",
weWillSendCode: "Мы вышлем вам код",
}
export default function Header({ locale = "ru", isAuthenticated = false, translations }: HeaderProps) {
const [isClient, setIsClient] = useState(false)
const [isCategoryOpen, setIsCategoryOpen] = useState(false)
const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false)
const [isLoginOpen, setIsLoginOpen] = useState(false)
const t = translations || DEFAULT_TRANSLATIONS
useEffect(() => {
setIsClient(true)
}, [])
const handleAuthClick = () => {
if (isAuthenticated) {
window.location.href = `/${locale}/me`
} else {
setIsLoginOpen(true)
}
}
const toggleCategoryMenu = () => setIsCategoryOpen(!isCategoryOpen)
const closeCategoryMenu = () => setIsCategoryOpen(false)
if (!isClient) return null
return (
<>
<header className="sticky top-0 z-50 w-full border-b bg-white shadow-sm">
<div className="container mx-auto px-4">
<div className="flex h-16 items-center justify-between gap-4">
<Link href="/" className="shrink-0">
<div className="relative h-8 w-[180px]">
<Image src={Logo || "/placeholder.svg"} alt="Logo" fill className="object-contain" priority />
</div>
</Link>
<Button
onClick={toggleCategoryMenu}
className="hidden gap-2 rounded-xl font-bold sm:flex hover:bg-[#005bff] bg-[#005bff] text-white"
size="lg"
>
{isCategoryOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
{t.catalog}
</Button>
<div className="flex items-center gap-2 sm:hidden">
<Button variant="ghost" size="icon" onClick={() => setIsMobileSearchOpen(true)}>
<Search className="h-5 w-5" />
</Button>
<LanguageSelector />
</div>
<div className="hidden sm:block">
<LanguageSelector />
</div>
<SearchBar isMobile={false} searchPlaceholder={t.search} className="hidden flex-1 md:flex" />
<ActionButtons
isAuthenticated={isAuthenticated}
onAuthClick={handleAuthClick}
translations={{
profile: t.profile,
login: t.login,
orders: t.orders,
favorites: t.favorites,
cart: t.cart,
}}
/>
</div>
<Link href="/openStore">
<Button variant="ghost" size="sm" className="relative flex gap-0.5 h-auto pb-2">
<Store className="h-5 w-5 text-gray-600" />
<span className="text-xs text-gray-700">{t.openStore}</span>
</Button>
</Link>
</div>
</header>
<CategoryMenu isOpen={isCategoryOpen} onClose={closeCategoryMenu} />
<SearchBar
isMobile={true}
isOpen={isMobileSearchOpen}
onClose={() => setIsMobileSearchOpen(false)}
searchPlaceholder={t.search}
/>
<AuthDialog
isOpen={isLoginOpen}
onClose={() => setIsLoginOpen(false)}
translations={{
enterPhone: t.enterPhone,
weWillSendCode: t.weWillSendCode,
phone: t.phone,
code: t.code,
send: t.send,
}}
/>
</>
)
}

View File

@@ -0,0 +1,177 @@
"use client"
import { useState, useEffect } from "react"
import Link from "next/link"
import { Menu, Heart, Truck, ShoppingCart, User } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { ScrollArea } from "@/components/ui/scroll-area"
import { useCategories, useCart, useFavorites, useOrders } from "@/lib/hooks"
interface MobileBottomNavProps {
locale?: string
isAuthenticated?: boolean
translations?: {
catalog: string
favorites: string
orders: string
cart: string
login: string
profile: string
}
onLoginClick?: () => void
}
export default function MobileBottomNav({
locale = "ru",
isAuthenticated = false,
translations,
onLoginClick,
}: MobileBottomNavProps) {
const [isClient, setIsClient] = useState(false)
const [isCategoryOpen, setIsCategoryOpen] = useState(false)
const { data: categories = [] } = useCategories()
const { data: cartData } = useCart()
const { data: favoritesData } = useFavorites()
const { data: ordersData } = useOrders()
const t = translations || {
catalog: "Каталог",
favorites: "Избранное",
orders: "Заказы",
cart: "Корзина",
login: "Войти",
profile: "Профиль",
}
useEffect(() => {
setIsClient(true)
}, [])
const handleAuthClick = () => {
if (isAuthenticated) {
window.location.href = `/${locale}/me`
} else if (onLoginClick) {
onLoginClick()
}
}
if (!isClient) return null
return (
<>
{/* Mobile Bottom Navigation */}
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t shadow-lg md:hidden">
<div className="flex items-center justify-around h-16 px-2">
{/* Catalog Button */}
<Button
variant="ghost"
size="sm"
className="flex-col gap-0.5 h-auto px-2 py-2"
onClick={() => setIsCategoryOpen(true)}
>
<Menu className="h-5 w-5 text-gray-600" />
<span className="text-xs text-gray-700">{t.catalog}</span>
</Button>
{/* Favorites Button */}
<Link href="/favorites">
<Button variant="ghost" size="sm" className="relative flex-col gap-0.5 h-auto px-2 py-2">
<div className="relative">
<Heart className="h-5 w-5 text-gray-600" />
<Badge
variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
>
{favoritesData?.length || 0}
</Badge>
</div>
<span className="text-xs text-gray-700">{t.favorites}</span>
</Button>
</Link>
{/* Orders Button */}
<Link href="/orders">
<Button variant="ghost" size="sm" className="relative flex-col gap-0.5 h-auto px-2 py-2">
<div className="relative">
<Truck className="h-5 w-5 text-gray-600" />
<Badge
variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
>
{ordersData?.length || 0}
</Badge>
</div>
<span className="text-xs text-gray-700">{t.orders}</span>
</Button>
</Link>
{/* Cart Button */}
<Link href="/cart">
<Button variant="ghost" size="sm" className="relative flex-col gap-0.5 h-auto px-2 py-2">
<div className="relative">
<ShoppingCart className="h-5 w-5 text-gray-600" />
<Badge
variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
>
{cartData?.count || 0}
</Badge>
</div>
<span className="text-xs text-gray-700">{t.cart}</span>
</Button>
</Link>
{/* Profile/Login Button */}
<Button variant="ghost" size="sm" className="flex-col gap-0.5 h-auto px-2 py-2" onClick={handleAuthClick}>
<User className="h-5 w-5 text-gray-600" />
<span className="text-xs text-gray-700">{isAuthenticated ? t.profile : t.login}</span>
</Button>
</div>
</div>
{/* Category Sheet/Drawer */}
<Sheet open={isCategoryOpen} onOpenChange={setIsCategoryOpen}>
<SheetContent side="left" className="w-[300px] p-0">
<SheetHeader className="p-4 border-b">
<SheetTitle>{t.catalog}</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-80px)]">
<div className="p-4">
{categories.map((category) => (
<div key={category.id} className="mb-4">
<Link
href={`/category/${category.slug}?category_id=${category.id}`}
onClick={() => setIsCategoryOpen(false)}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors font-semibold"
>
{category.icon_class && <i className={`${category.icon_class} text-xl`}></i>}
<span>{category.name}</span>
</Link>
{/* Subcategories */}
{category.children && category.children.length > 0 && (
<div className="ml-8 mt-2 space-y-1">
{category.children.map((child: any) => (
<Link
key={child.id}
href={`/category/${child.slug}?category_id=${child.id}`}
onClick={() => setIsCategoryOpen(false)}
className="block px-3 py-2 text-sm text-gray-600 hover:text-primary hover:bg-gray-50 rounded-lg transition-colors"
>
{child.name}
</Link>
))}
</div>
)}
</div>
))}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</>
)
}

View File

@@ -0,0 +1,98 @@
"use client"
import type React from "react"
import Link from "next/link"
import { User, Truck, Heart, ShoppingCart } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { useCart, useFavorites, useOrders } from "@/lib/hooks"
import { Skeleton } from "@/components/ui/skeleton"
interface ActionButtonsProps {
isAuthenticated: boolean
onAuthClick: () => void
translations: {
profile: string
login: string
orders: string
favorites: string
cart: string
}
}
interface ActionButtonData {
icon: React.ReactNode
label: string
href?: string
onClick?: () => void
badgeCount?: number
isLoading?: boolean
}
export default function ActionButtons({ isAuthenticated, onAuthClick, translations: t }: ActionButtonsProps) {
const { data: cartData, isLoading: cartLoading } = useCart()
const { data: favoritesData, isLoading: favoritesLoading } = useFavorites()
const { data: ordersData, isLoading: ordersLoading } = useOrders()
const buttons: ActionButtonData[] = [
{
icon: <User className="h-5 w-5 text-gray-600" />,
label: isAuthenticated ? t.profile : t.login,
onClick: onAuthClick,
},
{
icon: <Truck className="h-5 w-5 text-gray-600" />,
label: t.orders,
href: "/orders",
badgeCount: ordersData?.length || 0,
isLoading: ordersLoading,
},
{
icon: <Heart className="h-5 w-5 text-gray-600" />,
label: t.favorites,
href: "/favorites",
badgeCount: favoritesData?.length || 0,
isLoading: favoritesLoading,
},
{
icon: <ShoppingCart className="h-5 w-5 text-gray-600" />,
label: t.cart,
href: "/cart",
badgeCount: cartData?.count || 0,
isLoading: cartLoading,
},
]
return (
<div className="hidden items-center gap-1 md:flex">
{buttons.map((button, index) => (
<ActionButton key={index} {...button} />
))}
</div>
)
}
function ActionButton({ icon, label, href, onClick, badgeCount, isLoading }: ActionButtonData) {
const buttonContent = (
<Button variant="ghost" size="sm" className="relative flex-col gap-0.5 h-auto px-2 py-2" onClick={onClick}>
<div className="relative">
{icon}
{badgeCount !== undefined && (
<Badge
variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
>
{isLoading ? <Skeleton className="h-3 w-3 rounded-full" /> : badgeCount}
</Badge>
)}
</div>
<span className="text-xs text-gray-700">{label}</span>
</Button>
)
if (href) {
return <Link href={href}>{buttonContent}</Link>
}
return buttonContent
}

View File

@@ -0,0 +1,109 @@
import React, { useState } from "react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import Logo from "@/public/logo.png";
interface AuthDialogProps {
isOpen: boolean;
onClose: () => void;
translations: {
enterPhone: string;
weWillSendCode: string;
phone: string;
code: string;
send: string;
};
}
export default function AuthDialog({
isOpen,
onClose,
translations: t,
}: AuthDialogProps) {
const [phone, setPhone] = useState("993");
const [otp, setOtp] = useState("");
const [otpSent, setOtpSent] = useState(false);
const handleSendOtp = () => {
if (phone.length > 3) {
setOtpSent(true);
}
};
const handleLogin = () => {
// Here you can add authentication logic
resetDialog();
};
const resetDialog = () => {
onClose();
setOtpSent(false);
setPhone("993");
setOtp("");
};
const handleKeyPress = (e: React.KeyboardEvent, action: () => void) => {
if (e.key === "Enter") {
action();
}
};
return (
<Dialog open={isOpen} onOpenChange={resetDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center justify-center mb-4">
<div className="relative h-8 w-[180px]">
<Image src={Logo} alt="Logo" fill className="object-contain" />
</div>
</div>
<DialogTitle className="text-2xl text-center">
{t.enterPhone}
</DialogTitle>
<p className="text-center text-sm text-gray-600">
{t.weWillSendCode}
</p>
</DialogHeader>
<div className="space-y-4 mt-4">
<Input
type="tel"
placeholder={t.phone}
value={phone}
onChange={(e) => setPhone(e.target.value)}
className="h-12 rounded-xl"
onKeyDown={(e) => handleKeyPress(e, handleSendOtp)}
disabled={otpSent}
/>
{otpSent && (
<Input
type="text"
placeholder={t.code}
value={otp}
onChange={(e) => setOtp(e.target.value)}
className="h-12 rounded-xl"
onKeyDown={(e) => handleKeyPress(e, handleLogin)}
autoFocus
/>
)}
<Button
onClick={otpSent ? handleLogin : handleSendOtp}
className="w-full h-12 rounded-xl font-bold text-base"
size="lg"
>
{t.send}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,101 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useCategories } from "@/lib/hooks"
import { Skeleton } from "@/components/ui/skeleton"
interface Category {
id: number
name: string
slug: string
icon_class?: string
children?: Category[]
}
interface CategoryMenuProps {
isOpen: boolean
onClose: () => void
}
export default function CategoryMenu({ isOpen, onClose }: CategoryMenuProps) {
const [hoveredCategory, setHoveredCategory] = useState<number | null>(null)
const { data: categories, isLoading } = useCategories()
if (!isOpen) return null
const categoryList = categories || []
const activeCategory = hoveredCategory !== null ? categoryList[hoveredCategory] : null
return (
<div className="fixed left-0 right-0 top-22 z-40 bg-white border-b shadow-lg">
<div className="container mx-auto px-4">
<div className="flex">
<CategoryList
categories={categoryList}
isLoading={isLoading}
onCategoryHover={setHoveredCategory}
onCategoryClick={onClose}
/>
{activeCategory?.children && <SubcategoryList category={activeCategory} onSubcategoryClick={onClose} />}
</div>
</div>
</div>
)
}
interface CategoryListProps {
categories: any[]
isLoading: boolean
onCategoryHover: (index: number) => void
onCategoryClick: () => void
}
function CategoryList({ categories, isLoading, onCategoryHover, onCategoryClick }: CategoryListProps) {
return (
<div className="w-[280px] border-r">
<div className="max-h-[calc(100vh-4rem)] overflow-y-auto py-2">
{isLoading
? [1, 2, 3, 4, 5].map((i) => <Skeleton key={i} className="h-10 mx-4 my-2 rounded" />)
: categories.map((category, index) => (
<Link
key={category.id}
href={`/category/${category.slug}?category_id=${category.id}`}
onClick={onCategoryClick}
onMouseEnter={() => onCategoryHover(index)}
className="flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-gray-100 hover:text-primary transition-colors"
>
{category.icon_class && <i className={`${category.icon_class} text-xl`}></i>}
<span>{category.name}</span>
</Link>
))}
</div>
</div>
)
}
interface SubcategoryListProps {
category: any
onSubcategoryClick: () => void
}
function SubcategoryList({ category, onSubcategoryClick }: SubcategoryListProps) {
return (
<div className="flex-1 p-6">
<h3 className="text-xl font-semibold mb-4">{category.name}</h3>
<div className="grid grid-cols-3 gap-4">
{category.children?.map((subCategory: any) => (
<Link
key={subCategory.id}
href={`/category/${subCategory.slug}?category_id=${subCategory.id}`}
onClick={onSubcategoryClick}
className="text-gray-600 hover:text-black text-sm py-1 hover:underline"
>
{subCategory.name}
</Link>
))}
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,75 @@
import React, { useState } from "react";
import { Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
interface SearchBarProps {
isMobile: boolean;
searchPlaceholder: string;
isOpen?: boolean;
onClose?: () => void;
className?: string;
}
export default function SearchBar({
isMobile,
searchPlaceholder,
isOpen,
onClose,
className = "",
}: SearchBarProps) {
const [searchValue, setSearchValue] = useState("");
const handleSearch = (value: string) => {
setSearchValue(value);
// Here you can add search logic or API call
};
if (isMobile) {
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="top-4 translate-y-0">
<DialogHeader>
<DialogTitle>{searchPlaceholder}</DialogTitle>
</DialogHeader>
<div className="relative">
<Input
type="search"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => handleSearch(e.target.value)}
className="h-10 rounded-xl focus:border-[#005bff] focus-visible:border-[#005bff] focus-visible:ring-0 active:border-[#005bff]"
autoFocus
/>
</div>
</DialogContent>
</Dialog>
);
}
return (
<div className={`bg-[#005bff] rounded-xl ${className}`}>
<div className="w-full">
<Input
type="search"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => handleSearch(e.target.value)}
className="border-[#005bff] w-full rounded-xl border-2 focus-visible:ring-0 bg-white px-2"
/>
</div>
<Button
size="icon"
className="h-auto hover:bg-[#005bff] cursor-pointer bg-transparent flex items-center mr-1.5 text-white"
>
<Search className="h-5 w-5" />
</Button>
</div>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
import { Skeleton } from "@/components/ui/skeleton"
import ProductGridSkeleton from "./ProductGridSkeleton"
import CategorySkeleton from "./CategorySkeleton"
export default function HomeSkeleton() {
return (
<div className="px-4 md:px-8 lg:px-12 pt-8 pb-12 space-y-8">
{/* Hero Carousel Skeleton */}
<section className="rounded-2xl overflow-hidden">
<Skeleton className="w-full h-[200px] sm:h-[300px] md:h-[420px] bg-gray-200" />
</section>
{/* Categories Section Skeleton */}
<section className="bg-white rounded-2xl shadow-sm p-6">
<Skeleton className="h-6 w-32 mb-4 bg-gray-200" />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<CategorySkeleton key={i} />
))}
</div>
</section>
{/* Products Section Skeleton */}
<section className="bg-white rounded-2xl shadow-sm p-6">
<Skeleton className="h-6 w-32 mb-4 bg-gray-200" />
<ProductGridSkeleton count={10} columns="5" />
</section>
</div>
)
}

View File

@@ -0,0 +1,129 @@
import { Skeleton } from "@/components/ui/skeleton"
import ProductGridSkeleton from "./ProductGridSkeleton"
import CartItemSkeleton from "./CartItemSkeleton" // Added import for CartItemSkeleton
interface PageLoaderProps {
/**
* Type of page loading skeleton
* home, products, category, search, cart, favorites, orders, profile
*/
type?: "home" | "products" | "category" | "search" | "cart" | "favorites" | "orders" | "profile"
}
export default function PageLoader({ type = "products" }: PageLoaderProps) {
switch (type) {
case "home":
return (
<div className="px-4 md:px-8 lg:px-12 pt-8 pb-12 space-y-8">
{/* Hero Banner */}
<Skeleton className="w-full h-[300px] rounded-2xl bg-gray-200" />
{/* Categories */}
<div className="space-y-4">
<Skeleton className="h-6 w-32 bg-gray-200" />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="aspect-square bg-gray-200 rounded-xl" />
))}
</div>
</div>
{/* Products */}
<div className="space-y-4">
<Skeleton className="h-6 w-32 bg-gray-200" />
<ProductGridSkeleton count={8} columns="5" />
</div>
</div>
)
case "products":
case "search":
return (
<div className="px-4 md:px-8 lg:px-12 py-8">
<div className="space-y-4 mb-6">
<Skeleton className="h-8 w-40 bg-gray-200" />
</div>
<ProductGridSkeleton count={12} columns="5" />
</div>
)
case "category":
return (
<div className="container mx-auto px-4 py-8">
<div className="flex gap-6">
{/* Filters Sidebar */}
<div className="hidden sm:block w-[280px] space-y-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-5 w-24 bg-gray-200" />
<Skeleton className="h-4 w-full bg-gray-200" />
<Skeleton className="h-4 w-full bg-gray-200" />
<Skeleton className="h-4 w-3/4 bg-gray-200" />
</div>
))}
</div>
{/* Products */}
<div className="flex-1">
<ProductGridSkeleton count={12} columns="5" />
</div>
</div>
</div>
)
case "cart":
return (
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-8 w-40 mb-6 bg-gray-200" />
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1 space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<CartItemSkeleton key={i} />
))}
</div>
{/* Order Summary */}
<div className="lg:w-[420px]">
<div className="space-y-4 bg-gray-50 p-6 rounded-xl">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full bg-gray-200" />
))}
</div>
</div>
</div>
</div>
)
case "orders":
case "favorites":
return (
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-8 w-40 mb-6 bg-gray-200" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-64 w-full bg-gray-200 rounded-xl" />
))}
</div>
</div>
)
case "profile":
return (
<div className="min-h-screen bg-gray-50 p-4 pt-20">
<div className="container mx-auto max-w-2xl">
<Skeleton className="h-8 w-40 mb-6 bg-gray-200" />
<div className="bg-white p-6 rounded-xl space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-32 bg-gray-200" />
<Skeleton className="h-10 w-full bg-gray-200 rounded-lg" />
</div>
))}
</div>
</div>
</div>
)
default:
return <ProductGridSkeleton count={12} columns="5" />
}
}

View File

@@ -0,0 +1,23 @@
import { Skeleton } from "@/components/ui/skeleton"
import { Card } from "@/components/ui/card"
export default function ProductCardSkeleton() {
return (
<Card className="overflow-hidden rounded-xl">
{/* Image Skeleton */}
<Skeleton className="aspect-square w-full bg-gray-200" />
{/* Content Skeleton */}
<div className="p-3 space-y-3">
{/* Title skeleton - 2 lines */}
<div className="space-y-2">
<Skeleton className="h-4 w-full bg-gray-200" />
<Skeleton className="h-4 w-3/4 bg-gray-200" />
</div>
{/* Price skeleton */}
<Skeleton className="h-6 w-1/2 bg-gray-200" />
</div>
</Card>
)
}

View File

@@ -0,0 +1,24 @@
import ProductCardSkeleton from "./ProductCardSkeleton"
interface ProductGridSkeletonProps {
count?: number
columns?: "2" | "3" | "4" | "5"
}
export default function ProductGridSkeleton({ count = 8, columns = "4" }: ProductGridSkeletonProps) {
const gridClass =
{
"2": "grid-cols-2",
"3": "md:grid-cols-3",
"4": "md:grid-cols-4 lg:grid-cols-4",
"5": "md:grid-cols-4 xl:grid-cols-5",
}[columns] || "md:grid-cols-4"
return (
<div className={`grid grid-cols-2 sm:grid-cols-3 ${gridClass} gap-4`}>
{Array.from({ length: count }).map((_, i) => (
<ProductCardSkeleton key={i} />
))}
</div>
)
}

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

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

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

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

@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"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 }

View File

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