Обновлены настройки docker-compose для публикации порта Meilisearch. Внесены изменения в стили для мобильной версии таблицы размеров и модальных окон. Оптимизирован компонент корзины, убраны ненужные зависимости и улучшена анимация. Добавлены новые функции для адаптивного отображения таблицы размеров. Удалены устаревшие данные и комментарии в коде.
This commit is contained in:
parent
41c1385546
commit
2f30bdc783
@ -100,8 +100,8 @@ services:
|
||||
container_name: meilisearch
|
||||
hostname: meilisearch
|
||||
# Не публикуем порт наружу, доступ только через FastAPI/Nginx
|
||||
# ports:
|
||||
# - "7700:7700"
|
||||
ports:
|
||||
- "7700:7700"
|
||||
expose:
|
||||
- "7700" # Внутренний порт
|
||||
environment:
|
||||
|
||||
@ -3,19 +3,16 @@
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { Trash2, Plus, Minus, ArrowRight, ShoppingBag, Heart, ChevronLeft, Clock, ShieldCheck, Truck, Package } from "lucide-react"
|
||||
import { Trash2, Plus, Minus, ArrowRight, ShoppingBag, ChevronLeft, Clock, ShieldCheck, Truck, Package } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
|
||||
import { useCart } from "@/hooks/useCart"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { formatPrice } from "@/lib/utils"
|
||||
import { toast } from "@/components/ui/use-toast"
|
||||
import { ProductCard } from "@/components/product/product-card"
|
||||
import { normalizeProductImage } from "@/lib/catalog" // Импортируем нормализацию
|
||||
import React from "react" // Добавляем импорт React для React.memo
|
||||
import { normalizeProductImage } from "@/lib/catalog"
|
||||
import React from "react"
|
||||
|
||||
// Компонент для анимации изменения чисел
|
||||
const AnimatedNumber = ({ value, className }: { value: number, className?: string }) => {
|
||||
@ -45,38 +42,50 @@ const AnimatedPrice = ({ price, className }: { price: number, className?: string
|
||||
);
|
||||
};
|
||||
|
||||
// Убираем компонент CartItemImage
|
||||
|
||||
interface RecommendedProduct {
|
||||
id: number
|
||||
name: string
|
||||
price: number
|
||||
image: string
|
||||
slug: string
|
||||
}
|
||||
// Компонент для отображения товаров в корзине
|
||||
|
||||
export default function CartPage() {
|
||||
const { cart, loading, updateCartItem, removeFromCart, clearCart } = useCart()
|
||||
// Убираем useInView для ref1 и ref2, так как анимация будет управляться isFirstRender
|
||||
// const [ref1, inView1] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
// const [ref2, inView2] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [ref3, inView3] = useInView({ triggerOnce: true, threshold: 0.1 }) // Оставляем для будущих секций, если нужно
|
||||
const router = useRouter()
|
||||
// Используем isFirstRender вместо useInView для управления анимацией
|
||||
|
||||
const [processing, setProcessing] = useState<{ [key: number]: boolean }>({})
|
||||
|
||||
|
||||
// Флаг для отслеживания первого рендера
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
// const isFirstRender = useRef(true); // Удаляем isFirstRender
|
||||
|
||||
// Сбрасываем флаг первого рендера после монтирования
|
||||
// и инициализируем фоновые изображения, если они есть
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isFirstRender.current = false;
|
||||
// Сбрасываем флаг первого рендера
|
||||
// isFirstRender.current = false; // Удаляем
|
||||
|
||||
// Инициализация фоновых изображений для элементов с классом lazy-bg
|
||||
// Оставляем эту логику, т.к. она не связана напрямую с ошибкой гидратации
|
||||
const initLazyBackgrounds = () => {
|
||||
const lazyBackgrounds = document.querySelectorAll('.lazy-bg');
|
||||
if (lazyBackgrounds.length > 0) {
|
||||
lazyBackgrounds.forEach(bg => {
|
||||
const element = bg as HTMLElement;
|
||||
const bgUrl = element.getAttribute('data-bg');
|
||||
if (bgUrl) {
|
||||
element.style.backgroundImage = `url(${bgUrl})`; // Ensure url() wrapper
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Запускаем инициализацию фоновых изображений
|
||||
initLazyBackgrounds();
|
||||
|
||||
// Очистка при размонтировании
|
||||
// return () => { // Удаляем связанное с isFirstRender
|
||||
// isFirstRender.current = true;
|
||||
// };
|
||||
}, []); // Пустой массив зависимостей означает, что эффект запускается один раз после монтирования
|
||||
|
||||
const handleQuantityChange = async (itemId: number, newQuantity: number) => {
|
||||
if (processing[itemId]) return
|
||||
|
||||
|
||||
setProcessing(prev => ({ ...prev, [itemId]: true }))
|
||||
try {
|
||||
await updateCartItem(itemId, newQuantity)
|
||||
@ -87,7 +96,7 @@ export default function CartPage() {
|
||||
|
||||
const handleRemoveItem = async (itemId: number) => {
|
||||
if (processing[itemId]) return
|
||||
|
||||
|
||||
setProcessing(prev => ({ ...prev, [itemId]: true }))
|
||||
try {
|
||||
await removeFromCart(itemId)
|
||||
@ -96,9 +105,7 @@ export default function CartPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckout = () => {
|
||||
router.push('/checkout')
|
||||
}
|
||||
// Переход к оформлению заказа происходит через Link
|
||||
|
||||
// Calculate totals
|
||||
const subtotal = cart.total_amount ?? 0
|
||||
@ -109,14 +116,14 @@ export default function CartPage() {
|
||||
// Анимационные варианты
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.5 } }
|
||||
@ -152,9 +159,9 @@ export default function CartPage() {
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{loading ? (
|
||||
<motion.div
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
@ -169,11 +176,11 @@ export default function CartPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12">
|
||||
{/* Cart Items */}
|
||||
{/* Убираем ref1 и меняем логику animate */}
|
||||
<motion.div
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial={isFirstRender.current ? "hidden" : false} // Анимация только при первом рендере
|
||||
animate={isFirstRender.current ? "visible" : { opacity: 1 }} // После первого рендера просто держим видимым
|
||||
transition={isFirstRender.current ? { delay: 0.1 } : { duration: 0 }} // Небольшая задержка для контейнера при первом рендере
|
||||
initial="hidden" // Всегда начинаем со скрытого состояния
|
||||
animate="visible" // Анимируем к видимому состоянию
|
||||
// transition убрали, он будет взят из containerVariants
|
||||
className="md:col-span-2 space-y-6 md:space-y-8"
|
||||
>
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm mb-4">
|
||||
@ -195,28 +202,27 @@ export default function CartPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{cart.items.map((item, index) => (
|
||||
{cart.items.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.id}
|
||||
variants={itemVariants}
|
||||
initial={isFirstRender.current ? "hidden" : false}
|
||||
animate={isFirstRender.current ? "visible" : false}
|
||||
transition={isFirstRender.current ? { duration: 0.5, delay: index * 0.05 } : { duration: 0 }}
|
||||
variants={itemVariants} // Используем стандартные варианты для элементов
|
||||
// initial, animate, transition будут взяты из variants и AnimatePresence
|
||||
className="flex flex-col sm:flex-row gap-4 sm:items-center border-b pb-6 last:border-b-0 last:pb-0"
|
||||
>
|
||||
{/* Изображение товара через background-image */}
|
||||
{/* Изображение товара через Image компонент */}
|
||||
<div className="relative w-24 h-32 sm:w-32 sm:h-40 flex-shrink-0 rounded-md overflow-hidden border border-gray-200">
|
||||
{item.product_image ? (
|
||||
<Link href={`/product/${item.product_id}`}>
|
||||
<div
|
||||
className="w-full h-full bg-secondary/10 lazy-bg"
|
||||
data-bg={`url(${normalizeProductImage(item.product_image)})`}
|
||||
style={{
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
aria-label={item.product_name || "Товар"}
|
||||
/>
|
||||
<div className="relative w-full h-full">
|
||||
<Image
|
||||
src={(item.product_image)}
|
||||
alt={item.product_name || "Товар"}
|
||||
fill
|
||||
sizes="(max-width: 768px) 96px, 128px"
|
||||
className="object-cover"
|
||||
priority={index < 2} // Приоритетная загрузка первых двух изображений
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-100">
|
||||
@ -227,22 +233,22 @@ export default function CartPage() {
|
||||
|
||||
{/* Информация о товаре */}
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<Link
|
||||
href={`/product/${item.product_id}`}
|
||||
<Link
|
||||
href={`/catalog/${item.slug}`}
|
||||
className="text-primary hover:text-primary/80 transition-colors duration-200"
|
||||
>
|
||||
<h3 className="font-medium text-base sm:text-lg line-clamp-2">
|
||||
{item.product_name || "Товар"}
|
||||
</h3>
|
||||
</Link>
|
||||
|
||||
|
||||
{/* Вариант товара - размер и цвет */}
|
||||
<div className="text-sm text-gray-600">
|
||||
{item.variant_name && (
|
||||
<span>Размер: {item.variant_name}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Количество и цена */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 pt-2">
|
||||
<div className="flex items-center space-x-1.5">
|
||||
@ -259,11 +265,11 @@ export default function CartPage() {
|
||||
<Minus className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
|
||||
<span className="text-center w-8 select-none">
|
||||
<AnimatedNumber value={item.quantity} />
|
||||
</span>
|
||||
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@ -278,7 +284,7 @@ export default function CartPage() {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-end space-x-4">
|
||||
<div>
|
||||
<div className="text-sm text-gray-500">Цена:</div>
|
||||
@ -286,7 +292,7 @@ export default function CartPage() {
|
||||
<AnimatedPrice price={item.total_price} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@ -311,10 +317,11 @@ export default function CartPage() {
|
||||
|
||||
{/* Order Summary */}
|
||||
{/* Убираем ref2 и меняем логику анимации */}
|
||||
<motion.div
|
||||
initial={isFirstRender.current ? { opacity: 0, y: 20 } : false} // Анимация только при первом рендере
|
||||
animate={isFirstRender.current ? { opacity: 1, y: 0 } : { opacity: 1, y: 0 }} // После первого рендера просто держим видимым
|
||||
transition={isFirstRender.current ? { duration: 0.5, delay: 0.3 } : { duration: 0 }} // Анимация только при первом рендере
|
||||
<motion.div
|
||||
// Применяем простую анимацию появления для блока summary
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }} // Небольшая задержка после анимации товаров
|
||||
className="md:sticky md:top-24 h-fit hidden md:block"
|
||||
>
|
||||
<div className="border border-gray-100 p-6 rounded-lg bg-white shadow-sm">
|
||||
@ -337,7 +344,7 @@ export default function CartPage() {
|
||||
{shipping === 0 ? "Бесплатно" : `${formatPrice(shipping)}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex justify-between font-medium text-lg items-center">
|
||||
@ -349,7 +356,7 @@ export default function CartPage() {
|
||||
</div>
|
||||
|
||||
{/* Кнопка оформления заказа */}
|
||||
<Button
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary/90 py-6 text-base font-medium mt-6"
|
||||
disabled={cart.items.length === 0}
|
||||
asChild
|
||||
@ -395,13 +402,13 @@ export default function CartPage() {
|
||||
{/* Убираем кнопку-переключатель и делаем блок видимым по умолчанию */}
|
||||
<div className="block md:hidden">
|
||||
{/* Удалена кнопка-переключатель */}
|
||||
|
||||
|
||||
<div id="mobile-order-summary" className="bg-white p-6 rounded-lg shadow-sm mb-8 border border-gray-100"> {/* Убран класс 'hidden' */}
|
||||
<h2 className="text-xl font-medium text-primary mb-6 flex items-center">
|
||||
<ShoppingBag className="h-5 w-5 mr-2 text-primary" />
|
||||
Сводка заказа
|
||||
</h2>
|
||||
|
||||
|
||||
<div className="space-y-4 text-base">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-600">Товары ({cart.items_count}):</span>
|
||||
@ -416,7 +423,7 @@ export default function CartPage() {
|
||||
{shipping === 0 ? "Бесплатно" : `${formatPrice(shipping)}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex justify-between font-medium text-lg items-center">
|
||||
@ -426,8 +433,8 @@ export default function CartPage() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary/90 py-6 text-base font-medium mt-6"
|
||||
disabled={cart.items.length === 0}
|
||||
asChild
|
||||
@ -443,7 +450,7 @@ export default function CartPage() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -10,37 +10,6 @@ import { useWishlist } from "@/hooks/use-wishlist"
|
||||
import catalogService, { ProductDetails } from "@/lib/catalog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
const recommended = [
|
||||
{
|
||||
id: 5,
|
||||
name: "ЮБКА МИДИ ПЛИССЕ",
|
||||
price: 3490,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
slug: "skirt-midi",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "ПАЛЬТО ИЗ ШЕРСТИ",
|
||||
price: 12990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
slug: "coat-wool",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "ДЖЕМПЕР ИЗ КАШЕМИРА",
|
||||
price: 7990,
|
||||
oldPrice: 9990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
slug: "sweater-cashmere",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "РУБАШКА ОВЕРСАЙЗ",
|
||||
price: 4490,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
slug: "shirt-oversize",
|
||||
},
|
||||
]
|
||||
|
||||
export default function WishlistPage() {
|
||||
const { items, removeItem, clearWishlist } = useWishlist()
|
||||
@ -158,7 +127,7 @@ export default function WishlistPage() {
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Рекомендации */}
|
||||
<section className="py-12 md:py-20">
|
||||
{/* <section className="py-12 md:py-20">
|
||||
<h2 className="text-xl md:text-2xl font-semibold text-primary mb-8 uppercase tracking-tight">Рекомендуем вам</h2>
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{recommended.map((item) => (
|
||||
@ -174,7 +143,7 @@ export default function WishlistPage() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section> */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -88,7 +88,7 @@
|
||||
h1 {
|
||||
@apply font-light;
|
||||
}
|
||||
|
||||
|
||||
h2 {
|
||||
@apply font-normal;
|
||||
}
|
||||
@ -150,5 +150,31 @@
|
||||
.product-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
/* Стили для таблицы размеров на мобильных устройствах */
|
||||
.size-table-mobile .tabs-list {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.size-table-mobile .tab-trigger {
|
||||
padding: 0.25rem 0.5rem;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
/* Улучшение читаемости модальных окон на мобильных устройствах */
|
||||
.dialog-content-mobile {
|
||||
padding: 0.75rem !important;
|
||||
max-height: 90vh !important;
|
||||
overflow-y: auto !important;
|
||||
width: 95vw !important;
|
||||
}
|
||||
|
||||
/* Уменьшение отступов в таблице размеров */
|
||||
.size-table-mobile td,
|
||||
.size-table-mobile th {
|
||||
padding: 0.5rem 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -116,13 +116,13 @@ export function SiteHeader() {
|
||||
>
|
||||
Каталог
|
||||
</Link>
|
||||
<Link
|
||||
{/* <Link
|
||||
href="/account"
|
||||
className="flex items-center gap-2 text-base text-gray-700 hover:text-primary transition-colors"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
<span>Личный кабинет</span>
|
||||
</Link>
|
||||
</Link> */}
|
||||
</div>
|
||||
|
||||
{/* Дополнительные ссылки */}
|
||||
|
||||
@ -8,8 +8,11 @@ import {
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Ruler } from "lucide-react"
|
||||
import { SizeTable } from "./size-table"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
|
||||
export function SizeModal() {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
@ -18,7 +21,7 @@ export function SizeModal() {
|
||||
Таблица размеров
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl bg-white dark:bg-gray-900 p-6">
|
||||
<DialogContent className={`${isMobile ? 'dialog-content-mobile' : 'max-w-4xl p-6'} bg-white dark:bg-gray-900`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-semibold">Таблица размеров</DialogTitle>
|
||||
</DialogHeader>
|
||||
@ -28,4 +31,4 @@ export function SizeModal() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,63 +1,153 @@
|
||||
import { Ruler, Info } from "lucide-react"
|
||||
import { Ruler, Info, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import { useState } from "react"
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
// Данные размеров для повторного использования
|
||||
const sizeData = [
|
||||
{
|
||||
ruSize: "40-42",
|
||||
intSize: "XS",
|
||||
bust: "88-90",
|
||||
waist: "60-64",
|
||||
hips: "88-90"
|
||||
},
|
||||
{
|
||||
ruSize: "42-44",
|
||||
intSize: "S",
|
||||
bust: "90-92",
|
||||
waist: "64-68",
|
||||
hips: "90-94"
|
||||
},
|
||||
{
|
||||
ruSize: "44-46",
|
||||
intSize: "M",
|
||||
bust: "92-96",
|
||||
waist: "68-75",
|
||||
hips: "94-96"
|
||||
},
|
||||
{
|
||||
ruSize: "46-48",
|
||||
intSize: "L",
|
||||
bust: "96-100",
|
||||
waist: "75-80",
|
||||
hips: "96-104"
|
||||
},
|
||||
{
|
||||
ruSize: "48-50",
|
||||
intSize: "XL",
|
||||
bust: "100-108",
|
||||
waist: "80-84",
|
||||
hips: "104-108"
|
||||
}
|
||||
]
|
||||
|
||||
// Компонент для мобильной версии таблицы размеров
|
||||
function MobileSizeTable() {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
|
||||
const nextSize = () => {
|
||||
setActiveIndex((prev) => (prev === sizeData.length - 1 ? 0 : prev + 1))
|
||||
}
|
||||
|
||||
const prevSize = () => {
|
||||
setActiveIndex((prev) => (prev === 0 ? sizeData.length - 1 : prev - 1))
|
||||
}
|
||||
|
||||
const currentSize = sizeData[activeIndex]
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Button variant="outline" size="icon" onClick={prevSize}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-center font-medium">
|
||||
{currentSize.intSize} ({currentSize.ruSize})
|
||||
</div>
|
||||
<Button variant="outline" size="icon" onClick={nextSize}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 bg-muted/20 p-4 rounded-md">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm font-medium">Обхват груди:</span>
|
||||
<span className="text-sm">{currentSize.bust} см</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm font-medium">Обхват талии:</span>
|
||||
<span className="text-sm">{currentSize.waist} см</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm font-medium">Обхват бедер:</span>
|
||||
<span className="text-sm">{currentSize.hips} см</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Tabs defaultValue="xs" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-5 tabs-list">
|
||||
{sizeData.map((size) => (
|
||||
<TabsTrigger
|
||||
key={size.intSize}
|
||||
value={size.intSize.toLowerCase()}
|
||||
onClick={() => setActiveIndex(sizeData.findIndex(s => s.intSize === size.intSize))}
|
||||
className="tab-trigger"
|
||||
>
|
||||
{size.intSize}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Компонент для десктопной версии таблицы размеров
|
||||
function DesktopSizeTable() {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse bg-white dark:bg-gray-900">
|
||||
<thead>
|
||||
<tr className="border-b dark:border-gray-700">
|
||||
<th className="p-4 text-left font-medium text-gray-900 dark:text-gray-100">Российский размер</th>
|
||||
<th className="p-4 text-left font-medium text-gray-900 dark:text-gray-100">Размер производ-ля (INT)</th>
|
||||
<th className="p-4 text-left font-medium text-gray-900 dark:text-gray-100">Обхват груди, см</th>
|
||||
<th className="p-4 text-left font-medium text-gray-900 dark:text-gray-100">Обхват талии, см</th>
|
||||
<th className="p-4 text-left font-medium text-gray-900 dark:text-gray-100">Обхват бедер, см</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sizeData.map((size, index) => (
|
||||
<tr key={index} className="border-b dark:border-gray-700">
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">{size.ruSize}</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">{size.intSize}</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">{size.bust}</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">{size.waist}</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">{size.hips}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SizeTable() {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto space-y-8">
|
||||
{/* Таблица размеров */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse bg-white dark:bg-gray-900">
|
||||
<thead>
|
||||
<tr className="border-b dark:border-gray-700">
|
||||
<th className="p-4 text-left font-medium text-gray-900 dark:text-gray-100">Российский размер</th>
|
||||
<th className="p-4 text-left font-medium text-gray-900 dark:text-gray-100">Размер производ-ля (INT)</th>
|
||||
<th className="p-4 text-left font-medium text-gray-900 dark:text-gray-100">Обхват груди, см</th>
|
||||
<th className="p-4 text-left font-medium text-gray-900 dark:text-gray-100">Обхват талии, см</th>
|
||||
<th className="p-4 text-left font-medium text-gray-900 dark:text-gray-100">Обхват бедер, см</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b dark:border-gray-700">
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">40-42</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">XS</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">88-90</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">60-64</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">88-90</td>
|
||||
</tr>
|
||||
<tr className="border-b dark:border-gray-700">
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">42-44</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">S</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">90-92</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">64-68</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">90-94</td>
|
||||
</tr>
|
||||
<tr className="border-b dark:border-gray-700">
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">44-46</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">M</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">92-96</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">68-75</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">94-96</td>
|
||||
</tr>
|
||||
<tr className="border-b dark:border-gray-700">
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">46-48</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">L</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">96-100</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">75-80</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">96-104</td>
|
||||
</tr>
|
||||
<tr className="border-b dark:border-gray-700">
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">48-50</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">XL</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">100-108</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">80-84</td>
|
||||
<td className="p-4 text-gray-700 dark:text-gray-300">104-108</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className={`w-full max-w-4xl mx-auto space-y-8 ${isMobile ? 'size-table-mobile' : ''}`}>
|
||||
{/* Таблица размеров - адаптивная версия */}
|
||||
{isMobile ? <MobileSizeTable /> : <DesktopSizeTable />}
|
||||
|
||||
{/* Инструкции по измерению */}
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Изображение схемы измерений */}
|
||||
<div className="relative aspect-[3/4] bg-muted rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src="/images/size-guide.svg"
|
||||
@ -66,68 +156,143 @@ export function SizeTable() {
|
||||
className="object-contain p-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5" />
|
||||
1. ОБХВАТ ГРУДИ
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Сантиметровая лента должна проходить по наиболее выступающим точкам груди, сбоку - под подмышечными впадинами, обхватываю лопатки сзади.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Мобильная версия инструкций */}
|
||||
{isMobile ? (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5" />
|
||||
2. ОБХВАТ ТАЛИИ
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Измеряется горизонтально в самой узкой части талии. При измерении лента должна плотно (без натяжения) прилегать к телу.
|
||||
</p>
|
||||
</div>
|
||||
<Tabs defaultValue="bust" className="w-full">
|
||||
<TabsList className="w-full grid grid-cols-5 tabs-list">
|
||||
<TabsTrigger value="bust" className="tab-trigger">1</TabsTrigger>
|
||||
<TabsTrigger value="waist" className="tab-trigger">2</TabsTrigger>
|
||||
<TabsTrigger value="hips" className="tab-trigger">3</TabsTrigger>
|
||||
<TabsTrigger value="sleeves" className="tab-trigger">4</TabsTrigger>
|
||||
<TabsTrigger value="pants" className="tab-trigger">5</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5" />
|
||||
3. ОБХВАТ БЕДЕР
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Сантиметровая лента проходит строго горизонтально по наиболее выступающим точкам ягодиц.
|
||||
</p>
|
||||
</div>
|
||||
<TabsContent value="bust" className="mt-4 space-y-2">
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-4 w-4" />
|
||||
1. ОБХВАТ ГРУДИ
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Сантиметровая лента должна проходить по наиболее выступающим точкам груди, сбоку - под подмышечными впадинами, обхватываю лопатки сзади.
|
||||
</p>
|
||||
</TabsContent>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5" />
|
||||
4. ДЛИНА РУКАВОВ
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Измеряется сантиметровой лентой от шва соединения с проймой до нижнего края рукава.
|
||||
</p>
|
||||
</div>
|
||||
<TabsContent value="waist" className="mt-4 space-y-2">
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-4 w-4" />
|
||||
2. ОБХВАТ ТАЛИИ
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Измеряется горизонтально в самой узкой части талии. При измерении лента должна плотно (без натяжения) прилегать к телу.
|
||||
</p>
|
||||
</TabsContent>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5" />
|
||||
5. ДЛИНА БРЮЧИН
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Данная мерка снимается по боковому шву от верхнего края пояса до нижнего края брюк.
|
||||
</p>
|
||||
</div>
|
||||
<TabsContent value="hips" className="mt-4 space-y-2">
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-4 w-4" />
|
||||
3. ОБХВАТ БЕДЕР
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Сантиметровая лента проходит строго горизонтально по наиболее выступающим точкам ягодиц.
|
||||
</p>
|
||||
</TabsContent>
|
||||
|
||||
<div className="mt-8 p-4 bg-primary/5 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 flex-shrink-0 mt-1" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Для наиболее точного определения размера рекомендуем снять мерки с себя или похожей одежды, сверить их с таблицей размеров и только после этого сделать заказ.
|
||||
</p>
|
||||
<TabsContent value="sleeves" className="mt-4 space-y-2">
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-4 w-4" />
|
||||
4. ДЛИНА РУКАВОВ
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Измеряется сантиметровой лентой от шва соединения с проймой до нижнего края рукава.
|
||||
</p>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pants" className="mt-4 space-y-2">
|
||||
<h3 className="text-base font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-4 w-4" />
|
||||
5. ДЛИНА БРЮЧИН
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Данная мерка снимается по боковому шву от верхнего края пояса до нижнего края брюк.
|
||||
</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-4 p-3 bg-primary/5 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="h-4 w-4 flex-shrink-0 mt-1" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Для наиболее точного определения размера рекомендуем снять мерки с себя или похожей одежды, сверить их с таблицей размеров и только после этого сделать заказ.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Десктопная версия инструкций */
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5" />
|
||||
1. ОБХВАТ ГРУДИ
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Сантиметровая лента должна проходить по наиболее выступающим точкам груди, сбоку - под подмышечными впадинами, обхватываю лопатки сзади.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5" />
|
||||
2. ОБХВАТ ТАЛИИ
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Измеряется горизонтально в самой узкой части талии. При измерении лента должна плотно (без натяжения) прилегать к телу.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5" />
|
||||
3. ОБХВАТ БЕДЕР
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Сантиметровая лента проходит строго горизонтально по наиболее выступающим точкам ягодиц.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5" />
|
||||
4. ДЛИНА РУКАВОВ
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Измеряется сантиметровой лентой от шва соединения с проймой до нижнего края рукава.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Ruler className="h-5 w-5" />
|
||||
5. ДЛИНА БРЮЧИН
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Данная мерка снимается по боковому шву от верхнего края пояса до нижнего края брюк.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 p-4 bg-primary/5 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 flex-shrink-0 mt-1" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Для наиболее точного определения размера рекомендуем снять мерки с себя или похожей одежды, сверить их с таблицей размеров и только после этого сделать заказ.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -91,7 +91,9 @@ export function useCart() {
|
||||
// Обновление количества товара в корзине
|
||||
const updateCartItem = useCallback(async (id: number, quantity: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Не устанавливаем глобальный флаг загрузки, так как он используется для индикации загрузки всей корзины
|
||||
// Вместо этого используем локальный флаг processing в компоненте корзины для каждого элемента
|
||||
// setLoading(true);
|
||||
setError(null);
|
||||
const result = await cartStore.updateCartItem(id, quantity);
|
||||
|
||||
@ -112,14 +114,17 @@ export function useCart() {
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// Не сбрасываем глобальный флаг загрузки, так как мы его не устанавливали
|
||||
// setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
// Удаление товара из корзины
|
||||
const removeFromCart = useCallback(async (id: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Не устанавливаем глобальный флаг загрузки, так как он используется для индикации загрузки всей корзины
|
||||
// Вместо этого используем локальный флаг processing в компоненте корзины для каждого элемента
|
||||
// setLoading(true);
|
||||
setError(null);
|
||||
const result = await cartStore.removeFromCart(id);
|
||||
|
||||
@ -146,7 +151,8 @@ export function useCart() {
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
// Не сбрасываем глобальный флаг загрузки, так как мы его не устанавливали
|
||||
// setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
|
||||
@ -3,15 +3,30 @@ import { useCategories } from './useAdminApi';
|
||||
import { Category } from '@/lib/catalog-admin';
|
||||
|
||||
export default function useCategoriesCache(params: Record<string, any> = {}) {
|
||||
const { data, isLoading, error, refetch } = useCategories(params) as { data?: Category[], isLoading: boolean, error: any, refetch: () => void };
|
||||
const categories: Category[] = data || [];
|
||||
const { data, isLoading, error, refetch } = useCategories(params) as {
|
||||
data?: { categories?: Category[], success?: boolean, total?: number },
|
||||
isLoading: boolean,
|
||||
error: any,
|
||||
refetch: () => void
|
||||
};
|
||||
|
||||
// Извлекаем массив категорий из ответа API
|
||||
const categories: Category[] = data?.categories || [];
|
||||
|
||||
// Строим дерево категорий из flat-списка
|
||||
const getCategoryTree = () => {
|
||||
const map = new Map<number, Category & { children?: Category[] }>();
|
||||
|
||||
// Проверяем, что categories - это массив перед вызовом forEach
|
||||
if (!Array.isArray(categories)) {
|
||||
console.error('categories is not an array:', categories);
|
||||
return [];
|
||||
}
|
||||
|
||||
categories.forEach(cat => {
|
||||
map.set(cat.id, { ...cat, children: [] });
|
||||
});
|
||||
|
||||
const tree: (Category & { children?: Category[] })[] = [];
|
||||
map.forEach(cat => {
|
||||
if (cat.parent_id && map.has(cat.parent_id)) {
|
||||
@ -30,4 +45,4 @@ export default function useCategoriesCache(params: Record<string, any> = {}) {
|
||||
refetch,
|
||||
getCategoryTree,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -371,17 +371,17 @@ export function normalizeProductImage(imageUrl: string | null | undefined): stri
|
||||
}
|
||||
|
||||
// Обработка полного URL MinIO - преобразуем в относительный путь
|
||||
if (imageUrl.includes('45.129.128.113:9000/dressedforsuccess/')) {
|
||||
// Извлекаем только путь /dressedforsuccess/filename.jpg из полного URL
|
||||
const match = imageUrl.match(/\/dressedforsuccess\/[^\/]+\.\w+$/);
|
||||
if (match) {
|
||||
const relativePath = match[0];
|
||||
if (apiStatus.debugMode) {
|
||||
console.log(`Преобразование полного URL MinIO в относительный путь: ${imageUrl} -> ${relativePath}`);
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
}
|
||||
// if (imageUrl.includes('45.129.128.113:9000/dressedforsuccess/')) {
|
||||
// // Извлекаем только путь /dressedforsuccess/filename.jpg из полного URL
|
||||
// const match = imageUrl.match(/\/dressedforsuccess\/[^\/]+\.\w+$/);
|
||||
// if (match) {
|
||||
// const relativePath = match[0];
|
||||
// if (apiStatus.debugMode) {
|
||||
// console.log(`Преобразование полного URL MinIO в относительный путь: ${imageUrl} -> ${relativePath}`);
|
||||
// }
|
||||
// return relativePath;
|
||||
// }
|
||||
// }
|
||||
|
||||
// Если это data URI или blob, возвращаем как есть
|
||||
if (imageUrl.startsWith('data:') || imageUrl.startsWith('blob:')) {
|
||||
|
||||
BIN
frontend/public/images/.DS_Store
vendored
BIN
frontend/public/images/.DS_Store
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.8 MiB |
Loading…
Reference in New Issue
Block a user