first commit
This commit is contained in:
167
components/ProductCard.tsx
Normal file
167
components/ProductCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
components/empty-states/EmptyCart.tsx
Normal file
32
components/empty-states/EmptyCart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
components/empty-states/EmptyFavorites.tsx
Normal file
32
components/empty-states/EmptyFavorites.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
components/empty-states/EmptyOrders.tsx
Normal file
32
components/empty-states/EmptyOrders.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
components/empty-states/EmptySearch.tsx
Normal file
34
components/empty-states/EmptySearch.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
38
components/home/Carousel.tsx
Normal file
38
components/home/Carousel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
61
components/home/CategoryGrid.tsx
Normal file
61
components/home/CategoryGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
182
components/home/HomePage.tsx
Normal file
182
components/home/HomePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
components/home/ProductGrid.tsx
Normal file
112
components/home/ProductGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
56
components/home/mockData.tsx
Normal file
56
components/home/mockData.tsx
Normal 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,
|
||||
},
|
||||
]
|
||||
153
components/layout/Header.tsx
Normal file
153
components/layout/Header.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
177
components/layout/MobileBar.tsx
Normal file
177
components/layout/MobileBar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
98
components/layout/ui/ActionButtons.tsx
Normal file
98
components/layout/ui/ActionButtons.tsx
Normal 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
|
||||
}
|
||||
109
components/layout/ui/AuthDialog.tsx
Normal file
109
components/layout/ui/AuthDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
components/layout/ui/CategoryMenu.tsx
Normal file
101
components/layout/ui/CategoryMenu.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
components/layout/ui/LanguageSelector.tsx
Normal file
59
components/layout/ui/LanguageSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
75
components/layout/ui/SearchBar.tsx
Normal file
75
components/layout/ui/SearchBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
components/skeletons/CartItemSkeleton.tsx
Normal file
27
components/skeletons/CartItemSkeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
components/skeletons/CategorySkeleton.tsx
Normal file
17
components/skeletons/CategorySkeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
components/skeletons/HomeSkeleton.tsx
Normal file
30
components/skeletons/HomeSkeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
129
components/skeletons/PageLoader.tsx
Normal file
129
components/skeletons/PageLoader.tsx
Normal 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" />
|
||||
}
|
||||
}
|
||||
23
components/skeletons/ProductCardSkeleton.tsx
Normal file
23
components/skeletons/ProductCardSkeleton.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
components/skeletons/ProductGridSkeleton.tsx
Normal file
24
components/skeletons/ProductGridSkeleton.tsx
Normal 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
53
components/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 "@/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
46
components/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 "@/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
60
components/ui/button.tsx
Normal 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
92
components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
32
components/ui/checkbox.tsx
Normal file
32
components/ui/checkbox.tsx
Normal 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
143
components/ui/dialog.tsx
Normal 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
21
components/ui/input.tsx
Normal 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
24
components/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 "@/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 }
|
||||
45
components/ui/radio-group.tsx
Normal file
45
components/ui/radio-group.tsx
Normal 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 }
|
||||
58
components/ui/scroll-area.tsx
Normal file
58
components/ui/scroll-area.tsx
Normal 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
187
components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
28
components/ui/separator.tsx
Normal file
28
components/ui/separator.tsx
Normal 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
139
components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal 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
63
components/ui/slider.tsx
Normal 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
40
components/ui/sonner.tsx
Normal 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
66
components/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 "@/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
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal 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 }
|
||||
Reference in New Issue
Block a user