Обновлены настройки docker-compose для публикации порта Meilisearch. Внесены изменения в стили для мобильной версии таблицы размеров и модальных окон. Оптимизирован компонент корзины, убраны ненужные зависимости и улучшена анимация. Добавлены новые функции для адаптивного отображения таблицы размеров. Удалены устаревшие данные и комментарии в коде.

This commit is contained in:
ilya_zahvatkin 2025-05-01 20:15:28 +07:00
parent 41c1385546
commit 2f30bdc783
12 changed files with 433 additions and 242 deletions

View File

@ -100,8 +100,8 @@ services:
container_name: meilisearch
hostname: meilisearch
# Не публикуем порт наружу, доступ только через FastAPI/Nginx
# ports:
# - "7700:7700"
ports:
- "7700:7700"
expose:
- "7700" # Внутренний порт
environment:

View File

@ -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>
)

View File

@ -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>
)

View File

@ -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;
}
}

View File

@ -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>
{/* Дополнительные ссылки */}

View File

@ -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>
)
}
}

View File

@ -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>
)
}
}

View File

@ -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]);

View File

@ -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,
};
}
}

View File

@ -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:')) {

Binary file not shown.

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB