+
+ onSelectProduct(product.id)}
+ className="h-4 w-4 rounded-sm border-muted-foreground"
+ />
-
+ #{product.id}
+
+
+
+
+
+
{product.name || 'Без названия'}
@@ -141,18 +161,12 @@ const ProductsTable = ({ products, loading, onDelete }: ProductsTableProps) => {
-
+
- {product.category?.name ||
- (product.category_id ? `ID: ${product.category_id}` : '-')}
+ {product.category?.name || (product.category_id ? `ID: ${product.category_id}` : '-')}
-
-
- {sizesString}
-
-
-
+
{typeof product.price === 'number' && (
{product.price.toLocaleString('ru-RU')} ₽
@@ -164,7 +178,7 @@ const ProductsTable = ({ products, loading, onDelete }: ProductsTableProps) => {
)}
-
+
20 ? 'secondary' :
stockAmount > 10 ? 'default' :
@@ -173,7 +187,7 @@ const ProductsTable = ({ products, loading, onDelete }: ProductsTableProps) => {
{stockAmount > 0 ? stockAmount : 'Нет в наличии'}
-
+
@@ -195,18 +209,21 @@ const ProductsTable = ({ products, loading, onDelete }: ProductsTableProps) => {
-
-
+
-
-
+
+
- Открыть в магазине
+ Просмотреть в магазине
@@ -214,12 +231,12 @@ const ProductsTable = ({ products, loading, onDelete }: ProductsTableProps) => {
- onDelete(product.id)}
>
-
+
@@ -317,201 +334,304 @@ const Pagination = ({ currentPage, totalPages, onPageChange }: PaginationProps)
};
export default function ProductsPage() {
- const [products, setProducts] = useState([]);
+ const router = useRouter();
+ const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [searchQuery, setSearchQuery] = useState('');
+ const [search, setSearch] = useState('');
+ const [pageSize, setPageSize] = useState(10);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
- const [isDeleting, setIsDeleting] = useState(false);
- const router = useRouter();
+ const [selectedProducts, setSelectedProducts] = useState([]);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [productToDelete, setProductToDelete] = useState(null);
+ const [deleting, setDeleting] = useState(false);
- // Загрузка товаров
- const loadProducts = async (page = 1, search = '') => {
+ // Обработчик ошибок
+ const handleError = (err: any, message = 'Произошла ошибка') => {
+ console.error(`${message}:`, err);
+ setError(message);
+ toast.error(message);
+ };
+
+ // Загрузка данных
+ const loadProducts = useCallback(async (page = 1, searchQuery = '') => {
try {
setLoading(true);
- const response = await fetchProducts({
- page,
- search,
- limit: 10
+ setError(null);
+ console.log(`Загрузка товаров (страница ${page}, поиск: "${searchQuery}")`);
+
+ const response = await catalogService.getProducts({
+ skip: (page - 1) * pageSize,
+ limit: pageSize,
+ search: searchQuery
});
- console.log('Полученные товары:', response);
-
- // Проверяем разные форматы данных
- let productsData: Product[] = [];
-
- if (response.data) {
- // Проверяем, есть ли обертка product в ответе
- if (typeof response.data === 'object' && 'product' in response.data) {
- const productData = response.data.product;
- // Формат { data: { product: { items: [...] } } }
- if (productData && typeof productData === 'object' && 'items' in productData) {
- productsData = productData.items as Product[];
- // @ts-ignore - игнорируем ошибку типа, так как мы проверяем существование свойства
- setTotalPages(productData.total_pages || 1);
- }
- // Формат { data: { product: [...] } }
- else if (Array.isArray(productData)) {
- productsData = productData;
- setTotalPages(Math.ceil(productData.length / 10) || 1);
- }
- }
- // Формат { data: { items: [...] } }
- else if (typeof response.data === 'object' && 'items' in response.data) {
- productsData = response.data.items as Product[];
- // @ts-ignore - игнорируем ошибку типа, так как мы проверяем существование свойства
- setTotalPages(response.data.total_pages || 1);
- }
- // Формат { data: [...] } - массив товаров
- else if (Array.isArray(response.data)) {
- productsData = response.data;
- setTotalPages(Math.ceil(productsData.length / 10) || 1);
- }
- }
-
- // Отфильтровываем по поиску, если сервер не поддерживает поиск
- if (search && Array.isArray(productsData)) {
- productsData = productsData.filter(product =>
- product.name.toLowerCase().includes(search.toLowerCase()));
- }
-
- // Делаем пагинацию на клиенте, если сервер не поддерживает пагинацию
- if (page > 1 && Array.isArray(productsData) && response.data && !('total_pages' in response.data)) {
- const startIndex = (page - 1) * 10;
- productsData = productsData.slice(startIndex, startIndex + 10);
- }
-
- setProducts(productsData);
- setError(null);
+ console.log('Получено товаров:', response.products?.length || 0, 'из', response.total);
+ setProducts(response.products as unknown as ProductDetails[] || []);
+ setTotalPages(Math.ceil((response.total || 0) / pageSize) || 1);
+ setCurrentPage(page);
} catch (err) {
- console.error('Ошибка при загрузке товаров:', err);
- setError('Не удалось загрузить товары');
- setProducts([]);
+ handleError(err, 'Не удалось загрузить товары');
} finally {
setLoading(false);
}
- };
+ }, [pageSize]);
- // Загрузка при монтировании и изменении страницы/поиска
+ // Загрузка при монтировании компонента
useEffect(() => {
- loadProducts(currentPage, searchQuery);
- }, [currentPage]);
+ loadProducts(1, search);
+ }, [loadProducts]);
// Обработчик поиска
- const handleSearch = (e: React.FormEvent) => {
- e.preventDefault();
- setCurrentPage(1);
- loadProducts(1, searchQuery);
+ const handleSearch = () => {
+ loadProducts(1, search);
+ };
+
+ // Обработчик изменения страницы
+ const handlePageChange = (page: number) => {
+ if (page < 1 || page > totalPages) return;
+ setCurrentPage(page);
+ loadProducts(page, search);
+ };
+
+ // Обработчик обновления списка
+ const handleRefresh = () => {
+ loadProducts(currentPage, search);
};
// Обработчик удаления товара
const handleDelete = async (id: number) => {
- if (window.confirm('Вы уверены, что хотите удалить этот товар?')) {
- try {
- setIsDeleting(true);
- const response = await deleteProduct(id);
+ try {
+ setDeleting(true);
+ console.log(`Удаление товара с ID: ${id}`);
+
+ const success = await catalogService.deleteProduct(id);
+ if (success) {
+ console.log(`Товар с ID ${id} успешно удален`);
+ toast.success('Товар успешно удален');
- if (response.success) {
- toast({
- title: "Товар удален",
- description: "Товар успешно удален из каталога",
- variant: "default",
- });
- // Обновляем список товаров после удаления
- loadProducts(currentPage, searchQuery);
- } else {
- throw new Error(response.error || 'Ошибка при удалении');
+ // Обновляем список товаров
+ loadProducts(
+ // Если на странице остался 1 товар и мы его удалили, то переходим на предыдущую страницу
+ products.length === 1 && currentPage > 1 ? currentPage - 1 : currentPage,
+ search
+ );
+
+ // Если товар был выбран, удаляем его из выбранных
+ if (selectedProducts.includes(id)) {
+ setSelectedProducts(prev => prev.filter(productId => productId !== id));
}
- } catch (err) {
- console.error('Ошибка при удалении товара:', err);
- toast({
- title: "Ошибка",
- description: "Не удалось удалить товар",
- variant: "destructive",
- });
- } finally {
- setIsDeleting(false);
+ } else {
+ throw new Error('Не удалось удалить товар');
}
+ } catch (err) {
+ handleError(err, 'Не удалось удалить товар');
+ } finally {
+ setDeleting(false);
+ setDeleteDialogOpen(false);
+ setProductToDelete(null);
+ }
+ };
+
+ // Обработчик выбора товара
+ const handleSelectProduct = (id: number) => {
+ setSelectedProducts(prev => {
+ if (prev.includes(id)) {
+ return prev.filter(productId => productId !== id);
+ } else {
+ return [...prev, id];
+ }
+ });
+ };
+
+ // Обработчик выбора всех товаров
+ const handleSelectAll = (checked: boolean) => {
+ if (checked) {
+ setSelectedProducts(products.map(p => p.id));
+ } else {
+ setSelectedProducts([]);
+ }
+ };
+
+ // Показ диалога подтверждения удаления
+ const confirmDelete = (id: number) => {
+ setProductToDelete(id);
+ setDeleteDialogOpen(true);
+ };
+
+ // Обработчик удаления выбранных товаров
+ const handleDeleteSelected = async () => {
+ if (!selectedProducts.length) return;
+
+ try {
+ setDeleting(true);
+ let successCount = 0;
+
+ for (const id of selectedProducts) {
+ try {
+ const success = await catalogService.deleteProduct(id);
+ if (success) {
+ successCount++;
+ }
+ } catch (err) {
+ console.error(`Ошибка при удалении товара с ID ${id}:`, err);
+ }
+ }
+
+ if (successCount > 0) {
+ toast.success(`Удалено ${successCount} товаров`);
+ setSelectedProducts([]);
+ // Обновляем список товаров
+ loadProducts(currentPage, search);
+ } else {
+ toast.error('Не удалось удалить выбранные товары');
+ }
+ } catch (err) {
+ handleError(err, 'Ошибка при удалении товаров');
+ } finally {
+ setDeleting(false);
}
};
return (
-
+
+
+
Товары
+
+
+
+ Создать товар
+
+
+
+
+ {error && (
+
+ )}
+
-
- Управление товарами
-
-
-
-
- Добавить товар
-
-
-
-
-
- Добавить товар (полная форма)
-
-
+
+
+
Список товаров
+
+
+
+ Обновить
+
+
+ {selectedProducts.length > 0 && (
+
+
+ Удалить выбранные ({selectedProducts.length})
+
+ )}
+
-
-
-
+
+
(
diff --git a/frontend/components/admin/ProductImages.tsx b/frontend/components/admin/ProductImages.tsx
index 6233e11..6b527e7 100644
--- a/frontend/components/admin/ProductImages.tsx
+++ b/frontend/components/admin/ProductImages.tsx
@@ -1,34 +1,28 @@
import { useState, useEffect, useRef } from "react";
-import { productsApi } from "@/lib/api";
+import api from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
-import { Loader2, Upload, X, Check, AlertCircle, Image as ImageIcon } from "lucide-react";
+import { Loader2, Upload, X, Check, AlertCircle, Image as ImageIcon, Plus, Trash } from "lucide-react";
import { toast } from "sonner";
-
-// Интерфейсы
-interface ProductImage {
- id: string;
- url: string;
- is_primary: boolean;
- alt_text?: string;
-}
+import { ProductDetails, ProductImage } from '@/lib/catalog';
+import { getImageUrl } from '@/lib/catalog';
interface ProductImagesProps {
- productId: string;
- productName: string;
+ product: ProductDetails;
+ onUpdate: (images: ProductImage[]) => void;
}
-export function ProductImages({ productId, productName }: ProductImagesProps) {
+export default function ProductImages({ product, onUpdate }: ProductImagesProps) {
const fileInputRef = useRef
(null);
- const [images, setImages] = useState([]);
+ const [images, setImages] = useState(product.images || []);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
- const [imageToDelete, setImageToDelete] = useState(null);
+ const [imageToDelete, setImageToDelete] = useState(null);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [currentImage, setCurrentImage] = useState(null);
const [altText, setAltText] = useState("");
@@ -38,10 +32,10 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
const fetchImages = async () => {
try {
setLoading(true);
- const response = await productsApi.getImages(productId);
+ const response = await api.get(`/catalog/products/${product.id}/images`);
- if (response.status === 200 && response.data) {
- setImages(response.data as ProductImage[]);
+ if (response) {
+ setImages(response);
} else {
throw new Error("Не удалось загрузить изображения");
}
@@ -56,51 +50,47 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
};
fetchImages();
- }, [productId]);
+ }, [product.id]);
// Обработчик выбора файла
const handleFileChange = async (e: React.ChangeEvent) => {
const files = e.target.files;
- if (!files || files.length === 0) return;
-
+ if (!files) return;
+
try {
setUploading(true);
-
const formData = new FormData();
- formData.append('file', files[0]);
- formData.append('is_primary', images.length === 0 ? 'true' : 'false');
- const response = await productsApi.uploadImage(productId, formData);
+ for (let i = 0; i < files.length; i++) {
+ formData.append('images', files[i]);
+ }
+
+ const response = await api.post(`/catalog/products/${product.id}/images`, formData);
- if (response.status === 201 && response.data) {
- // Добавляем новое изображение в список
- setImages(prev => [...prev, response.data as ProductImage]);
- toast("Успех", {
- description: "Изображение успешно загружено",
+ if (response) {
+ setImages(response);
+ toast("Успешно", {
+ description: "Изображения загружены",
});
- } else {
- throw new Error(response.error || "Не удалось загрузить изображение");
}
} catch (error) {
- console.error("Ошибка при загрузке изображения:", error);
+ console.error('Ошибка при загрузке изображений:', error);
toast("Ошибка", {
- description: "Не удалось загрузить изображение",
+ description: "Не удалось загрузить изображения",
});
} finally {
setUploading(false);
- // Сбрасываем значение input, чтобы можно было загрузить тот же файл повторно
- if (fileInputRef.current) {
- fileInputRef.current.value = "";
- }
}
};
// Установка изображения как основного
- const setAsPrimary = async (imageId: string) => {
+ const setAsPrimary = async (imageId: number) => {
try {
- const response = await productsApi.updateImage(imageId, { is_primary: true });
+ const response = await api.put(`/catalog/products/${product.id}/images/${imageId}`, {
+ is_primary: true
+ });
- if (response.status === 200 && response.data) {
+ if (response) {
// Обновляем список изображений
setImages(prev =>
prev.map(img => ({
@@ -112,8 +102,6 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
toast("Успех", {
description: "Основное изображение обновлено",
});
- } else {
- throw new Error(response.error || "Не удалось обновить изображение");
}
} catch (error) {
console.error("Ошибка при обновлении изображения:", error);
@@ -135,12 +123,12 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
if (!currentImage) return;
try {
- const response = await productsApi.updateImage(currentImage.id, {
+ const response = await api.put(`/catalog/products/${product.id}/images/${currentImage.id}`, {
alt_text: altText,
is_primary: currentImage.is_primary
});
- if (response.status === 200 && response.data) {
+ if (response) {
// Обновляем изображение в списке
setImages(prev =>
prev.map(img => img.id === currentImage.id ? {
@@ -154,8 +142,6 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
});
setEditDialogOpen(false);
- } else {
- throw new Error(response.error || "Не удалось обновить изображение");
}
} catch (error) {
console.error("Ошибка при обновлении изображения:", error);
@@ -166,7 +152,7 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
};
// Открытие диалога удаления
- const openDeleteDialog = (imageId: string) => {
+ const openDeleteDialog = (imageId: number) => {
setImageToDelete(imageId);
setDeleteDialogOpen(true);
};
@@ -176,9 +162,9 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
if (!imageToDelete) return;
try {
- const response = await productsApi.deleteImage(imageToDelete);
+ const response = await api.delete(`/catalog/products/${product.id}/images/${imageToDelete}`);
- if (response.status === 204 || response.status === 200) {
+ if (response) {
// Удаляем изображение из списка
const newImages = images.filter(img => img.id !== imageToDelete);
setImages(newImages);
@@ -193,8 +179,6 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
});
setDeleteDialogOpen(false);
- } else {
- throw new Error(response.error || "Не удалось удалить изображение");
}
} catch (error) {
console.error("Ошибка при удалении изображения:", error);
@@ -203,115 +187,91 @@ export function ProductImages({ productId, productName }: ProductImagesProps) {
});
}
};
-
+
+ const handleRemoveImage = (index: number) => {
+ setImages(images.filter((_, i) => i !== index));
+ };
+
+ const handleReorderImages = (fromIndex: number, toIndex: number) => {
+ const newImages = [...images];
+ const [movedImage] = newImages.splice(fromIndex, 1);
+ newImages.splice(toIndex, 0, movedImage);
+ setImages(newImages);
+ };
+
return (
- Изображения продукта
-
- Управление изображениями товара "{productName}"
-
-
-
-
-
- Загрузить новое изображение
-
-
+
+
Изображения товара
+
- {uploading && (
-
- )}
+ document.getElementById('image-upload')?.click()}
+ disabled={uploading}
+ >
+
+ {uploading ? 'Загрузка...' : 'Загрузить'}
+
-
- Поддерживаемые форматы: JPG, PNG, WebP. Максимальный размер: 5 МБ.
-
-
- {loading ? (
-
-
-
- ) : images.length === 0 ? (
-
-
-
Нет изображений
-
- У этого продукта еще нет изображений. Загрузите изображения, чтобы покупатели могли увидеть товар.
-
-
- ) : (
-
- {images.map((image) => (
+
+
+
+ {images.length === 0 ? (
+
+ Нет изображений
+
+ ) : (
+ images.map((image, index) => (
-
-
-
-
-
- {!image.is_primary && (
-
setAsPrimary(image.id)}
- title="Сделать основным"
- >
-
-
- )}
+
+
openEditDialog(image)}
- title="Редактировать описание"
+ onClick={() => handleReorderImages(index, Math.max(0, index - 1))}
+ disabled={index === 0}
>
-
+ ↑
+
+ handleReorderImages(index, Math.min(images.length - 1, index + 1))}
+ disabled={index === images.length - 1}
+ >
+ ↓
openDeleteDialog(image.id)}
- title="Удалить"
+ onClick={() => handleRemoveImage(index)}
>
-
+
-
- {image.is_primary && (
-
- Основное
-
- )}
-
- {image.alt_text && (
-
- {image.alt_text}
-
- )}
- ))}
-
- )}
+ ))
+ )}
+
{/* Диалог редактирования alt-текста */}
diff --git a/frontend/components/admin/ProductPreview.tsx b/frontend/components/admin/ProductPreview.tsx
new file mode 100644
index 0000000..c36dfa9
--- /dev/null
+++ b/frontend/components/admin/ProductPreview.tsx
@@ -0,0 +1,129 @@
+import { ProductDetails } from '@/lib/catalog';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { getImageUrl } from '@/lib/catalog';
+import { Eye } from 'lucide-react';
+import Link from 'next/link';
+
+interface ProductPreviewProps {
+ product: ProductDetails;
+}
+
+export default function ProductPreview({ product }: ProductPreviewProps) {
+ const getProductImageUrl = (product: ProductDetails): string | null => {
+ if (product.images && Array.isArray(product.images) && product.images.length > 0) {
+ if (typeof product.images[0] === 'string') {
+ return getImageUrl(product.images[0]);
+ }
+ else if (typeof product.images[0] === 'object' && product.images[0] !== null) {
+ const img = product.images[0] as any;
+ return getImageUrl(img.image_url || '');
+ }
+ }
+ return null;
+ };
+
+ const getProductStock = (product: ProductDetails): number => {
+ if (product.variants && Array.isArray(product.variants) && product.variants.length > 0) {
+ return product.variants.reduce((sum: number, variant) =>
+ sum + (typeof variant.stock === 'number' ? variant.stock : 0), 0);
+ }
+ return typeof product.stock === 'number' ? product.stock : 0;
+ };
+
+ const imageUrl = getProductImageUrl(product);
+ const stockAmount = getProductStock(product);
+
+ return (
+
+
+
+ Предпросмотр товара
+
+
+ Открыть в магазине
+
+
+
+
+
+ {/* Изображение */}
+
+ {imageUrl ? (
+
+ ) : (
+
+ Нет изображения
+
+ )}
+
+
+ {/* Основная информация */}
+
+
{product.name}
+
+
+ {product.is_active ? "Активен" : "Неактивен"}
+
+ 20 ? 'secondary' :
+ stockAmount > 10 ? 'default' :
+ stockAmount > 0 ? 'destructive' : 'outline'
+ }>
+ {stockAmount > 0 ? `В наличии: ${stockAmount}` : 'Нет в наличии'}
+
+
+
+
+ {/* Цены */}
+
+
+ {(product.price || 0).toLocaleString('ru-RU')} ₽
+
+ {product.discount_price && (
+
+ {(product.discount_price || 0).toLocaleString('ru-RU')} ₽
+
+ )}
+
+
+ {/* Категория */}
+
+ Категория: {product.category?.name || 'Не указана'}
+
+
+ {/* Варианты */}
+ {product.variants && product.variants.length > 0 && (
+
+
Варианты:
+
+ {product.variants.map((variant, index) => (
+
+
{variant.size?.name || 'Без размера'}
+
+ Артикул: {variant.sku || '-'}
+
+
+ Остаток: {variant.stock || 0}
+
+
+ ))}
+
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/admin/ProductVariants.tsx b/frontend/components/admin/ProductVariants.tsx
index f5f1d2e..74fdbd2 100644
--- a/frontend/components/admin/ProductVariants.tsx
+++ b/frontend/components/admin/ProductVariants.tsx
@@ -1,439 +1,228 @@
import { useState, useEffect } from "react";
-import { productsApi, sizesApi } from "@/lib/api";
+import api from "@/lib/api";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow
-} from "@/components/ui/table";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { Loader2, Plus, Edit, Trash, AlertCircle } from "lucide-react";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Plus, Trash, Save } from "lucide-react";
import { toast } from "sonner";
-
-// Интерфейсы
-interface Size {
- id: string;
- name: string;
- value: string;
-}
-
-interface ProductVariant {
- id: string;
- product_id: string;
- size_id: string;
- size: Size;
- sku: string;
- stock: number;
- price?: number;
-}
+import { ProductDetails, ProductVariant, ProductVariantCreate, ProductVariantUpdate, Size } from '@/lib/catalog';
interface ProductVariantsProps {
- productId: string;
- productName: string;
- defaultPrice: number;
+ product: ProductDetails;
+ onUpdate: (variants: ProductVariant[]) => void;
}
-export function ProductVariants({ productId, productName, defaultPrice }: ProductVariantsProps) {
- const [variants, setVariants] = useState([]);
+export default function ProductVariants({ product, onUpdate }: ProductVariantsProps) {
+ const [variants, setVariants] = useState(product.variants || []);
const [sizes, setSizes] = useState([]);
const [loading, setLoading] = useState(true);
- const [isDialogOpen, setIsDialogOpen] = useState(false);
- const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
- const [currentVariant, setCurrentVariant] = useState(null);
-
- // Состояние формы
- const [selectedSizeId, setSelectedSizeId] = useState("");
- const [sku, setSku] = useState("");
- const [stock, setStock] = useState(0);
- const [price, setPrice] = useState(undefined);
- const [isEditing, setIsEditing] = useState(false);
-
- // Загрузка данных о вариантах и размерах
+ const [saving, setSaving] = useState(false);
+
+ // Загрузка списка размеров
useEffect(() => {
- const fetchData = async () => {
+ const fetchSizes = async () => {
try {
- setLoading(true);
-
- // Загружаем варианты продукта
- const variantsResponse = await productsApi.getVariants(productId);
- if (variantsResponse.status === 200 && variantsResponse.data) {
- setVariants(variantsResponse.data as ProductVariant[]);
- }
-
- // Загружаем доступные размеры
- const sizesResponse = await sizesApi.getAll();
- if (sizesResponse.status === 200 && sizesResponse.data) {
- setSizes(sizesResponse.data as Size[]);
+ const response = await api.get('/catalog/sizes');
+ if (response) {
+ setSizes(response);
}
} catch (error) {
- console.error("Ошибка при загрузке данных:", error);
- toast("Ошибка при загрузке данных", {
- description: "Не удалось загрузить варианты продукта или размеры",
+ console.error('Ошибка при загрузке размеров:', error);
+ toast("Ошибка", {
+ description: "Не удалось загрузить список размеров"
});
} finally {
setLoading(false);
}
};
-
- fetchData();
- }, [productId]);
-
- // Открытие диалога для создания нового варианта
- const openCreateDialog = () => {
- setIsEditing(false);
- setCurrentVariant(null);
- setSelectedSizeId("");
- setSku("");
- setStock(0);
- setPrice(defaultPrice);
- setIsDialogOpen(true);
- };
-
- // Открытие диалога для редактирования варианта
- const openEditDialog = (variant: ProductVariant) => {
- setIsEditing(true);
- setCurrentVariant(variant);
- setSelectedSizeId(variant.size_id);
- setSku(variant.sku);
- setStock(variant.stock);
- setPrice(variant.price !== undefined ? variant.price : defaultPrice);
- setIsDialogOpen(true);
- };
-
- // Открытие диалога для удаления варианта
- const openDeleteDialog = (variant: ProductVariant) => {
- setCurrentVariant(variant);
- setIsDeleteDialogOpen(true);
- };
-
- // Создание нового варианта
- const createVariant = async () => {
- if (!selectedSizeId) {
- toast("Выберите размер", {
- description: "Необходимо выбрать размер для варианта продукта",
+
+ fetchSizes();
+ }, []);
+
+ const handleAddVariant = () => {
+ // Берем первый доступный размер из списка
+ const firstSize = sizes[0];
+ if (!firstSize) {
+ toast("Ошибка", {
+ description: "Нет доступных размеров для создания варианта"
});
return;
}
+
+ // Временно создаем вариант для отображения в интерфейсе
+ const tempVariant: ProductVariant = {
+ id: Date.now(), // Временный ID
+ product_id: product.id,
+ size_id: firstSize.id,
+ sku: "",
+ stock: 0,
+ is_active: true,
+ created_at: new Date().toISOString()
+ };
+ setVariants([...variants, tempVariant]);
+ };
+
+ const handleRemoveVariant = (index: number) => {
+ setVariants(variants.filter((_, i) => i !== index));
+ };
+
+ const handleUpdateVariant = (index: number, field: keyof ProductVariantUpdate, value: any) => {
+ const newVariants = [...variants];
+ newVariants[index] = {
+ ...newVariants[index],
+ [field]: value
+ };
+ setVariants(newVariants);
+ };
+
+ const handleSave = async () => {
try {
- const variantData = {
- product_id: productId,
- size_id: selectedSizeId,
- sku: sku,
- stock: stock,
- price: price !== defaultPrice ? price : undefined,
- };
-
- const response = await productsApi.addVariant(productId, variantData);
-
- if (response.status === 201 && response.data) {
- // Добавляем новый вариант в список
- const newVariant = response.data as ProductVariant;
- setVariants(prev => [...prev, newVariant]);
-
- toast("Вариант создан", {
- description: "Вариант продукта успешно создан",
+ setSaving(true);
+ const response = await api.put(`/catalog/products/${product.id}/variants`, {
+ variants: variants.map(variant => ({
+ size_id: variant.size_id,
+ sku: variant.sku,
+ stock: variant.stock,
+ is_active: variant.is_active
+ }))
+ });
+
+ if (response) {
+ setVariants(response.variants);
+ onUpdate(response.variants);
+ toast("Успешно", {
+ description: "Варианты товара обновлены"
});
-
- setIsDialogOpen(false);
- } else {
- throw new Error(response.error || "Не удалось создать вариант");
}
} catch (error) {
- console.error("Ошибка при создании варианта:", error);
+ console.error("Ошибка при сохранении вариантов:", error);
toast("Ошибка", {
- description: "Не удалось создать вариант продукта",
+ description: "Не удалось сохранить варианты товара"
});
+ } finally {
+ setSaving(false);
}
};
-
- // Обновление варианта
- const updateVariant = async () => {
- if (!currentVariant) return;
-
- try {
- const variantData = {
- product_id: productId,
- size_id: selectedSizeId,
- sku: sku,
- stock: stock,
- price: price !== defaultPrice ? price : undefined,
- };
-
- const response = await productsApi.updateVariant(currentVariant.id, variantData);
-
- if (response.status === 200 && response.data) {
- // Обновляем вариант в списке
- const updatedVariant = response.data as ProductVariant;
- setVariants(prev =>
- prev.map(v => v.id === updatedVariant.id ? updatedVariant : v)
- );
-
- toast("Вариант обновлен", {
- description: "Вариант продукта успешно обновлен",
- });
-
- setIsDialogOpen(false);
- } else {
- throw new Error(response.error || "Не удалось обновить вариант");
- }
- } catch (error) {
- console.error("Ошибка при обновлении варианта:", error);
- toast("Ошибка", {
- description: "Не удалось обновить вариант продукта",
- });
- }
+
+ // Проверяем, используется ли размер в других вариантах
+ const isSizeUsed = (sizeId: number, currentVariantId: number) => {
+ return variants.some(v => v.size_id === sizeId && v.id !== currentVariantId);
};
-
- // Удаление варианта
- const deleteVariant = async () => {
- if (!currentVariant) return;
-
- try {
- const response = await productsApi.deleteVariant(currentVariant.id);
-
- if (response.status === 204 || response.status === 200) {
- // Удаляем вариант из списка
- setVariants(prev => prev.filter(v => v.id !== currentVariant.id));
-
- toast("Вариант удален", {
- description: "Вариант продукта успешно удален",
- });
-
- setIsDeleteDialogOpen(false);
- } else {
- throw new Error(response.error || "Не удалось удалить вариант");
- }
- } catch (error) {
- console.error("Ошибка при удалении варианта:", error);
- toast("Ошибка", {
- description: "Не удалось удалить вариант продукта",
- });
- }
- };
-
- // Проверка, доступен ли размер для выбора (не используется в существующих вариантах)
- const isSizeAvailable = (sizeId: string) => {
- return !variants.some(v => v.size_id === sizeId && (!currentVariant || v.id !== currentVariant.id));
- };
-
- // Форматирование цены
- const formatPrice = (price: number) => {
- return new Intl.NumberFormat("ru-RU", {
- style: "currency",
- currency: "RUB",
- }).format(price);
- };
-
+
return (
- Варианты продукта
-
- Управление размерами и наличием товара "{productName}"
-
-
-
-
-
-
- Добавить вариант
-
-
-
- {loading ? (
-
-
+
+
+ Варианты товара
+
+ Добавьте варианты товара с разными размерами и характеристиками
+
- ) : variants.length === 0 ? (
-
-
-
Нет вариантов
-
- У этого продукта еще нет вариантов. Добавьте варианты, чтобы указать доступные размеры и количество.
-
-
-
- Добавить первый вариант
+
+
+
+ Добавить вариант
+
+
+
+ {saving ? 'Сохранение...' : 'Сохранить'}
+
+
+
+ {loading ? (
+
+ Загрузка...
+
+ ) : sizes.length === 0 ? (
+
+ Нет доступных размеров. Сначала добавьте размеры в справочник.
+
) : (
-
-
-
-
- Размер
- Артикул (SKU)
- В наличии
- Цена
- Действия
-
-
-
- {variants.map((variant) => (
-
- {variant.size?.name || "—"}
- {variant.sku || "—"}
- {variant.stock}
-
- {variant.price !== undefined ? formatPrice(variant.price) : formatPrice(defaultPrice)}
-
-
-
-
openEditDialog(variant)}
+
+ {variants.map((variant, index) => (
+
+
+ Размер
+ handleUpdateVariant(index, "size_id", parseInt(value))}
+ >
+
+
+
+
+ {sizes.map(size => (
+
-
-
- openDeleteDialog(variant)}
- >
-
-
-
-
-
- ))}
-
-
+ {size.name} ({size.value})
+
+ ))}
+
+
+
+
+ Артикул (SKU)
+ handleUpdateVariant(index, "sku", e.target.value)}
+ placeholder="Уникальный артикул"
+ />
+
+
+ Наличие
+ handleUpdateVariant(index, "stock", parseInt(e.target.value))}
+ min="0"
+ />
+
+
+ Статус
+ handleUpdateVariant(index, "is_active", value === "active")}
+ >
+
+
+
+
+ Активный
+ Неактивный
+
+
+
+
+ handleRemoveVariant(index)}
+ >
+
+
+
+
+ ))}
)}
-
- {/* Диалог создания/редактирования варианта */}
-
-
-
-
- {isEditing ? "Редактировать вариант" : "Добавить вариант"}
-
-
- {isEditing
- ? "Измените параметры варианта продукта"
- : "Заполните информацию о новом варианте продукта"}
-
-
-
-
-
- Размер
-
-
-
-
-
- {sizes.map((size) => (
-
- {size.name} ({size.value})
-
- ))}
-
-
-
-
-
- Артикул (SKU)
- setSku(e.target.value)}
- placeholder="Например: BLK-T-M"
- />
-
-
-
- Количество в наличии
- setStock(parseInt(e.target.value) || 0)}
- />
-
-
-
-
- Цена (опционально, если отличается от основной)
-
-
setPrice(parseFloat(e.target.value) || 0)}
- placeholder={`По умолчанию: ${formatPrice(defaultPrice)}`}
- />
-
- Оставьте значение по умолчанию, если цена не отличается от основной
-
-
-
-
-
- setIsDialogOpen(false)}>
- Отмена
-
-
- {isEditing ? "Сохранить" : "Добавить"}
-
-
-
-
-
- {/* Диалог подтверждения удаления */}
-
-
-
- Удалить вариант
-
- Вы уверены, что хотите удалить этот вариант продукта?
- Это действие нельзя отменить.
-
-
-
- setIsDeleteDialogOpen(false)}>
- Отмена
-
-
- Удалить
-
-
-
-
);
diff --git a/frontend/components/admin/Sidebar.tsx b/frontend/components/admin/Sidebar.tsx
index da6e95c..f50d6e8 100644
--- a/frontend/components/admin/Sidebar.tsx
+++ b/frontend/components/admin/Sidebar.tsx
@@ -10,7 +10,8 @@ import {
Users,
Settings,
Menu,
- X
+ X,
+ Ruler
} from 'lucide-react';
// Массив навигации
@@ -20,11 +21,17 @@ const navigation = [
{ name: 'Товары', href: '/admin/products', icon: Package },
{ name: 'Категории', href: '/admin/categories', icon: Layers },
{ name: 'Коллекции', href: '/admin/collections', icon: Grid },
+ { name: 'Размеры', href: '/admin/sizes', icon: Ruler },
{ name: 'Пользователи', href: '/admin/users', icon: Users },
{ name: 'Настройки', href: '/admin/settings', icon: Settings },
];
-export default function Sidebar({ isOpen, setIsOpen }) {
+interface SidebarProps {
+ isOpen: boolean;
+ setIsOpen: (isOpen: boolean) => void;
+}
+
+export default function Sidebar({ isOpen, setIsOpen }: SidebarProps) {
const router = useRouter();
return (
diff --git a/frontend/components/admin/SizesManager.tsx b/frontend/components/admin/SizesManager.tsx
new file mode 100644
index 0000000..aecc95e
--- /dev/null
+++ b/frontend/components/admin/SizesManager.tsx
@@ -0,0 +1,383 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { Plus, Pencil, Trash2, Loader2, Check, X } from 'lucide-react';
+import { toast } from 'sonner';
+import { z } from 'zod';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+// UI компоненты
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Switch } from '@/components/ui/switch';
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table';
+import { ScrollArea } from '@/components/ui/scroll-area';
+
+// API и типы
+import api, { ApiResponse } from '@/lib/api';
+import { Size, sizeService } from '@/lib/catalog';
+
+// Схема валидации для формы размера
+const sizeFormSchema = z.object({
+ name: z.string().min(1, 'Название размера обязательно'),
+ value: z.string().min(1, 'Значение размера обязательно'),
+ category_id: z.number().optional(),
+ is_active: z.boolean().default(true),
+});
+
+type SizeFormValues = z.infer;
+
+// Компонент для управления размерами
+export function SizesManager() {
+ const [sizes, setSizes] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [showDialog, setShowDialog] = useState(false);
+ const [editingSize, setEditingSize] = useState(null);
+ const [searchTerm, setSearchTerm] = useState('');
+
+ // Инициализация формы
+ const form = useForm({
+ resolver: zodResolver(sizeFormSchema),
+ defaultValues: {
+ name: '',
+ value: '',
+ is_active: true,
+ },
+ });
+
+ // Загрузка списка размеров
+ const loadSizes = async () => {
+ setLoading(true);
+ try {
+ const response = await sizeService.getSizes();
+ if (response) {
+ setSizes(response);
+ }
+ } catch (error) {
+ console.error('Ошибка при загрузке размеров:', error);
+ toast.error('Не удалось загрузить размеры');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Загрузка при монтировании компонента
+ useEffect(() => {
+ loadSizes();
+ }, []);
+
+ // Открытие диалога для создания нового размера
+ const handleAddSize = () => {
+ form.reset({
+ name: '',
+ value: '',
+ is_active: true,
+ });
+ setEditingSize(null);
+ setShowDialog(true);
+ };
+
+ // Открытие диалога для редактирования размера
+ const handleEditSize = (size: Size) => {
+ form.reset({
+ name: size.name,
+ value: size.value,
+ is_active: size.is_active,
+ });
+ setEditingSize(size);
+ setShowDialog(true);
+ };
+
+ // Обработчик удаления размера
+ const handleDeleteSize = async (id: number) => {
+ if (!confirm('Вы уверены, что хотите удалить этот размер?')) {
+ return;
+ }
+
+ try {
+ const success = await sizeService.deleteSize(id);
+ if (success) {
+ setSizes(sizes.filter(size => size.id !== id));
+ toast.success('Размер успешно удален');
+ } else {
+ throw new Error('Не удалось удалить размер');
+ }
+ } catch (error) {
+ console.error('Ошибка при удалении размера:', error);
+ toast.error('Ошибка при удалении размера');
+ }
+ };
+
+ // Обработчик переключения активности размера
+ const handleToggleActive = async (size: Size) => {
+ try {
+ const updatedSize = {
+ ...size,
+ is_active: !size.is_active,
+ };
+
+ const result = await sizeService.updateSize(updatedSize);
+ if (result) {
+ setSizes(sizes.map(s => s.id === size.id ? result : s));
+ toast.success(`Размер ${result.is_active ? 'активирован' : 'деактивирован'}`);
+ } else {
+ throw new Error('Не удалось обновить размер');
+ }
+ } catch (error) {
+ console.error('Ошибка при обновлении статуса размера:', error);
+ toast.error('Ошибка при изменении статуса размера');
+ }
+ };
+
+ // Обработчик сохранения размера
+ const onSubmit = async (values: SizeFormValues) => {
+ try {
+ let result: Size | null;
+
+ if (editingSize) {
+ // Обновление существующего размера
+ result = await sizeService.updateSize({
+ ...values,
+ id: editingSize.id
+ });
+
+ if (result) {
+ setSizes(sizes.map(size => size.id === editingSize.id ? result! : size));
+ toast.success('Размер успешно обновлен');
+ } else {
+ throw new Error('Не удалось обновить размер');
+ }
+ } else {
+ // Создание нового размера
+ result = await sizeService.createSize(values);
+
+ if (result) {
+ setSizes([...sizes, result]);
+ toast.success('Размер успешно создан');
+ } else {
+ throw new Error('Не удалось создать размер');
+ }
+ }
+
+ setShowDialog(false);
+ } catch (error) {
+ console.error('Ошибка при сохранении размера:', error);
+ toast.error('Ошибка при сохранении размера');
+ }
+ };
+
+ // Фильтрация размеров по поисковому запросу
+ const filteredSizes = sizes.filter(size =>
+ size.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ size.value.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+
+ return (
+
+
+
+
+
+ Управление размерами
+
+ Создавайте и редактируйте размеры для товаров
+
+
+
+
+ Добавить размер
+
+
+
+
+ {/* Поиск */}
+
+ setSearchTerm(e.target.value)}
+ className="max-w-sm"
+ />
+
+
+ {loading ? (
+
+
+
+ ) : filteredSizes.length === 0 ? (
+
+ {searchTerm ? 'Размеры не найдены' : 'Нет доступных размеров'}
+
+ ) : (
+
+
+
+
+ Название
+ Значение
+ Статус
+ Действия
+
+
+
+ {filteredSizes.map((size) => (
+
+ {size.name}
+ {size.value}
+
+ handleToggleActive(size)}
+ >
+ {size.is_active ? : }
+
+
+
+
+
handleEditSize(size)}
+ >
+
+
+
handleDeleteSize(size.id)}
+ >
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ {/* Диалог для создания/редактирования размера */}
+
+
+
+
+ {editingSize ? 'Редактировать размер' : 'Добавить новый размер'}
+
+
+ {editingSize
+ ? 'Измените информацию о размере и нажмите "Сохранить"'
+ : 'Заполните форму для создания нового размера'}
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/cart/CartItem.tsx b/frontend/components/cart/CartItem.tsx
new file mode 100644
index 0000000..50d1cc4
--- /dev/null
+++ b/frontend/components/cart/CartItem.tsx
@@ -0,0 +1,102 @@
+"use client";
+
+import React from 'react';
+import { Minus, Plus, Trash2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { CartItem as CartItemType } from '@/types/cart';
+import { formatPrice, getProperImageUrl } from '@/lib/utils';
+import Image from 'next/image';
+import Link from 'next/link';
+
+interface CartItemProps {
+ item: CartItemType;
+ onUpdateQuantity: (id: number, quantity: number) => Promise;
+ onRemove: (id: number) => Promise;
+}
+
+export function CartItem({ item, onUpdateQuantity, onRemove }: CartItemProps) {
+ const handleIncreaseQuantity = () => {
+ onUpdateQuantity(item.id, item.quantity + 1);
+ };
+
+ const handleDecreaseQuantity = () => {
+ if (item.quantity > 1) {
+ onUpdateQuantity(item.id, item.quantity - 1);
+ }
+ };
+
+ const handleRemove = () => {
+ onRemove(item.id);
+ };
+
+ return (
+
+
+ {item.product_image ? (
+
+ ) : (
+
+ Нет фото
+
+ )}
+
+
+
+
+
+
+
+ {item.product_name}
+
+
+
+ {item.variant_name}
+
+
+
+ {formatPrice(item.total_price)}
+
+
+
+
+
+
+
+
+
{item.quantity}
+
+
+
+
+
+
+
+ Удалить
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/cart/CartSummary.tsx b/frontend/components/cart/CartSummary.tsx
new file mode 100644
index 0000000..13010f4
--- /dev/null
+++ b/frontend/components/cart/CartSummary.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import React from 'react';
+import { useRouter } from 'next/navigation';
+import { ShoppingBag } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
+
+interface CartSummaryProps {
+ itemsCount: number;
+ totalAmount: number;
+ disabled?: boolean;
+}
+
+export function CartSummary({ itemsCount, totalAmount, disabled = false }: CartSummaryProps) {
+ const router = useRouter();
+
+ const handleCheckout = () => {
+ router.push('/checkout');
+ };
+
+ return (
+
+
+ Сводка заказа
+
+
+
+ Товары ({itemsCount})
+ {totalAmount.toLocaleString('ru-RU')} ₽
+
+
+ Доставка
+ Рассчитывается при оформлении
+
+
+
+ Итого
+ {totalAmount.toLocaleString('ru-RU')} ₽
+
+
+
+
+
+
+ {itemsCount === 0 ? 'Корзина пуста' : 'Оформить заказ'}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/cart/EmptyCart.tsx b/frontend/components/cart/EmptyCart.tsx
new file mode 100644
index 0000000..57c261b
--- /dev/null
+++ b/frontend/components/cart/EmptyCart.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import Link from 'next/link';
+import { ShoppingBag } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+
+export function EmptyCart() {
+ return (
+
+
+
+
+
Ваша корзина пуста
+
+ Похоже, вы еще не добавили товары в корзину.
+ Предлагаем вам ознакомиться с нашим каталогом и выбрать что-то для себя.
+
+
+
+ Перейти в каталог
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/cart/MiniCart.tsx b/frontend/components/cart/MiniCart.tsx
new file mode 100644
index 0000000..7831b3a
--- /dev/null
+++ b/frontend/components/cart/MiniCart.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import React from 'react';
+import Link from 'next/link';
+import Image from 'next/image';
+import { ShoppingBag, X } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { formatPrice, getProperImageUrl } from '@/lib/utils';
+import { useCart } from '@/hooks/useCart';
+import { Separator } from '@/components/ui/separator';
+
+interface MiniCartProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export function MiniCart({ isOpen, onClose }: MiniCartProps) {
+ const { cart, removeFromCart } = useCart();
+ const isEmpty = cart.items.length === 0;
+
+ if (!isOpen) return null;
+
+ // Ограничиваем количество отображаемых товаров
+ const displayedItems = cart.items.slice(0, 3);
+ const hasMoreItems = cart.items.length > 3;
+
+ return (
+
+
+
+
Корзина ({cart.total_items})
+
+
+
+
+
+ {isEmpty ? (
+
+ ) : (
+ <>
+
+ {displayedItems.map((item) => (
+
+
+ {item.product_image ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {item.product_name}
+
+
+ {item.variant_name} x {item.quantity}
+
+
+ {formatPrice(item.price)}
+
+
+
+
removeFromCart(item.id)}
+ className="text-neutral-400 hover:text-red-500 transition-colors self-start mt-1"
+ >
+
+
+
+ ))}
+
+ {hasMoreItems && (
+
+ И еще {cart.items.length - 3} {cart.items.length - 3 === 1 ? 'товар' : 'товаров'}...
+
+ )}
+
+
+
+
+
+ Итого:
+ {formatPrice(cart.total_price)}
+
+
+
+
+
+ Просмотр корзины
+
+
+
+ >
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/checkout/AddressForm.tsx b/frontend/components/checkout/AddressForm.tsx
new file mode 100644
index 0000000..a33068c
--- /dev/null
+++ b/frontend/components/checkout/AddressForm.tsx
@@ -0,0 +1,148 @@
+"use client";
+
+import React from 'react';
+import { useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+import * as z from 'zod';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { ShippingAddress } from '@/types/order';
+
+const addressSchema = z.object({
+ address_line1: z.string().min(5, 'Адрес должен быть не менее 5 символов'),
+ address_line2: z.string().optional(),
+ city: z.string().min(2, 'Укажите город'),
+ state: z.string().min(2, 'Укажите область/регион'),
+ postal_code: z.string().min(5, 'Укажите почтовый индекс'),
+ country: z.string().min(2, 'Укажите страну'),
+ is_default: z.boolean().default(false)
+});
+
+type AddressFormData = z.infer;
+
+interface AddressFormProps {
+ onSubmit: (data: AddressFormData) => void;
+ initialData?: ShippingAddress;
+ isSubmitting?: boolean;
+}
+
+export function AddressForm({ onSubmit, initialData, isSubmitting = false }: AddressFormProps) {
+ const form = useForm({
+ resolver: zodResolver(addressSchema),
+ defaultValues: initialData || {
+ address_line1: '',
+ address_line2: '',
+ city: '',
+ state: '',
+ postal_code: '',
+ country: 'Россия',
+ is_default: false
+ }
+ });
+
+ return (
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/checkout/CheckoutSummary.tsx b/frontend/components/checkout/CheckoutSummary.tsx
new file mode 100644
index 0000000..a669ada
--- /dev/null
+++ b/frontend/components/checkout/CheckoutSummary.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
+import { Separator } from "@/components/ui/separator";
+import { CartItem as CartItemType } from "@/types/cart";
+import { formatPrice } from "@/lib/utils";
+import { useState } from "react";
+import { Loader2 } from "lucide-react";
+
+interface CheckoutSummaryProps {
+ cartItems: CartItemType[];
+ totalAmount: number;
+ onPlaceOrder: () => Promise;
+}
+
+export function CheckoutSummary({ cartItems, totalAmount, onPlaceOrder }: CheckoutSummaryProps) {
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const handlePlaceOrder = async () => {
+ try {
+ setIsSubmitting(true);
+ await onPlaceOrder();
+ } catch (error) {
+ console.error('Ошибка при оформлении заказа:', error);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+
+ Ваш заказ
+
+
+ {cartItems.map((item) => (
+
+
+
+ {item.product_name} {item.variant_name && `(${item.variant_name})`}
+
+
+ {item.quantity} шт. x {formatPrice(item.product_price)}
+
+
+
{formatPrice(item.total_price)}
+
+ ))}
+
+
+
+
+
Подытог
+
{formatPrice(totalAmount)}
+
+
+
+
+
+
+
+
Итого
+
{formatPrice(totalAmount)}
+
+
+
+
+ {isSubmitting ? (
+ <>
+
+ Оформление...
+ >
+ ) : (
+ "Оформить заказ"
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/checkout/PaymentMethodSelector.tsx b/frontend/components/checkout/PaymentMethodSelector.tsx
new file mode 100644
index 0000000..7f9bb5d
--- /dev/null
+++ b/frontend/components/checkout/PaymentMethodSelector.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import React from 'react';
+import {
+ CreditCard,
+ Wallet,
+ Building,
+ TruckIcon
+} from 'lucide-react';
+import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
+import { Label } from '@/components/ui/label';
+import { PaymentMethod } from '@/types/order';
+
+interface PaymentMethodSelectorProps {
+ value: PaymentMethod;
+ onChange: (value: PaymentMethod) => void;
+ disabled?: boolean;
+}
+
+interface PaymentOption {
+ value: PaymentMethod;
+ label: string;
+ description: string;
+ icon: React.ReactNode;
+}
+
+export function PaymentMethodSelector({ value, onChange, disabled = false }: PaymentMethodSelectorProps) {
+ const paymentOptions: PaymentOption[] = [
+ {
+ value: 'credit_card',
+ label: 'Кредитная карта',
+ description: 'Оплата картой Visa, MasterCard, MIR',
+ icon:
+ },
+ {
+ value: 'paypal',
+ label: 'PayPal',
+ description: 'Оплата через электронный кошелек PayPal',
+ icon:
+ },
+ {
+ value: 'bank_transfer',
+ label: 'Банковский перевод',
+ description: 'Оплата через банковский перевод',
+ icon:
+ },
+ {
+ value: 'cash_on_delivery',
+ label: 'Наличными при получении',
+ description: 'Оплата при доставке курьеру или в пункте выдачи',
+ icon:
+ }
+ ];
+
+ return (
+ onChange(value as PaymentMethod)}
+ className="space-y-4"
+ disabled={disabled}
+ >
+ {paymentOptions.map((option) => (
+
+
+
+
+ {option.icon}
+
+
+
{option.label}
+
{option.description}
+
+
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/checkout/address-form.tsx b/frontend/components/checkout/address-form.tsx
new file mode 100644
index 0000000..6090691
--- /dev/null
+++ b/frontend/components/checkout/address-form.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { DeliveryMethod } from "@/app/(main)/checkout/page";
+
+interface Address {
+ city: string;
+ street: string;
+ house: string;
+ apartment: string;
+ postalCode: string;
+}
+
+interface AddressFormProps {
+ address: Address;
+ setAddress: (address: Address) => void;
+ deliveryMethod: DeliveryMethod;
+}
+
+export default function AddressForm({ address, setAddress, deliveryMethod }: AddressFormProps) {
+ // Создаем локальное состояние для обработки формы
+ const [localAddress, setLocalAddress] = useState(address);
+
+ // Обновляем родительский компонент при изменении локального состояния
+ useEffect(() => {
+ setAddress(localAddress);
+ }, [localAddress, setAddress]);
+
+ // Обработчик изменения полей формы
+ const handleChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ setLocalAddress(prev => ({ ...prev, [name]: value }));
+ };
+
+ return (
+
+ {/* Город */}
+
+
+ Город *
+
+
+
+
+ {/* Улица */}
+
+
+ Улица *
+
+
+
+
+
+
+ {/* Индекс - только для СДЭК */}
+ {deliveryMethod === "cdek" && (
+
+
+ Почтовый индекс
+
+
+
+ Почтовый индекс поможет быстрее доставить заказ
+
+
+ )}
+
+ {/* Дополнительная информация для выбранного способа доставки */}
+ {deliveryMethod === "cdek" ? (
+
+
+ После оформления заказа вы сможете выбрать пункт выдачи СДЭК из списка доступных в вашем городе.
+
+
+ ) : (
+
+
+ Курьер доставит заказ по указанному адресу в Новокузнецке. Время доставки будет согласовано по телефону.
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/checkout/delivery-method-selector.tsx b/frontend/components/checkout/delivery-method-selector.tsx
new file mode 100644
index 0000000..4bd1302
--- /dev/null
+++ b/frontend/components/checkout/delivery-method-selector.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Label } from "@/components/ui/label";
+import { Truck, Box } from "lucide-react";
+import { DeliveryMethod } from "@/app/(main)/checkout/page";
+
+interface DeliveryMethodSelectorProps {
+ selected: DeliveryMethod;
+ onSelect: (method: DeliveryMethod) => void;
+}
+
+export default function DeliveryMethodSelector({ selected, onSelect }: DeliveryMethodSelectorProps) {
+ return (
+ onSelect(value as DeliveryMethod)}
+ className="space-y-4"
+ >
+ {/* Способ доставки - СДЭК */}
+
+
+
+
+
+ СДЭК
+
+
+ Доставка в пункт выдачи или постамат СДЭК
+
+
от 300 ₽
+
+
+
+ {/* Способ доставки - Курьер */}
+
+
+
+
+
+ Курьерская доставка
+
+
+ Доставка курьером по Новокузнецку
+
+
400 ₽
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/checkout/order-comment.tsx b/frontend/components/checkout/order-comment.tsx
new file mode 100644
index 0000000..c6161e7
--- /dev/null
+++ b/frontend/components/checkout/order-comment.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+
+interface OrderCommentProps {
+ comment: string;
+ setComment: (comment: string) => void;
+}
+
+export default function OrderComment({ comment, setComment }: OrderCommentProps) {
+ return (
+
+
+ Комментарий к заказу
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/checkout/order-summary.tsx b/frontend/components/checkout/order-summary.tsx
new file mode 100644
index 0000000..c40946e
--- /dev/null
+++ b/frontend/components/checkout/order-summary.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import Image from "next/image";
+import { formatPrice } from "@/lib/utils";
+import { Cart } from "@/types/cart";
+import { Separator } from "@/components/ui/separator";
+import { ShoppingBag, Truck, Package } from "lucide-react";
+
+interface OrderSummaryProps {
+ cart: Cart;
+}
+
+export default function OrderSummary({ cart }: OrderSummaryProps) {
+ // Рассчитываем общую стоимость товаров
+ const subtotal = cart.items?.reduce((total, item) => {
+ return total + (item.price || 0) * item.quantity;
+ }, 0) || 0;
+
+ // Фиксированная стоимость доставки (в реальном приложении она бы зависела от способа доставки)
+ const shippingCost = subtotal > 5000 ? 0 : 400;
+
+ // Итоговая сумма заказа
+ const total = subtotal + shippingCost;
+
+ return (
+
+ {/* Заголовок */}
+
+
+
+ Товары в корзине:
+
+
{cart.items?.length || 0}
+
+
+ {/* Список товаров в корзине */}
+
+ {cart.items?.map((item) => (
+
+ {/* Изображение товара */}
+
+ {item.image || item.product_image ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Информация о товаре */}
+
+
{item.name || item.product_name || "Товар"}
+
+ {(item.size || item.variant_name) && (
+ Размер: {item.size || item.variant_name}
+ )}
+ {(item.size || item.variant_name) && item.color && • }
+ {item.color && Цвет: {item.color} }
+
+
+ {item.quantity} × {formatPrice(item.price || 0)}
+ {formatPrice((item.price || 0) * item.quantity)}
+
+
+
+ ))}
+
+
+
+
+ {/* Расчет стоимости */}
+
+
+ Сумма товаров:
+ {formatPrice(subtotal)}
+
+
+ Доставка:
+
+ {shippingCost === 0 ? (
+ Бесплатно
+ ) : (
+ formatPrice(shippingCost)
+ )}
+
+
+ {shippingCost === 0 && subtotal > 0 && (
+
+
+ Бесплатная доставка при заказе от 5 000 ₽
+
+ )}
+
+
+
+
+ {/* Итоговая сумма */}
+
+ Итого:
+ {formatPrice(total)}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/checkout/payment-method-selector.tsx b/frontend/components/checkout/payment-method-selector.tsx
new file mode 100644
index 0000000..b5da6a7
--- /dev/null
+++ b/frontend/components/checkout/payment-method-selector.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Label } from "@/components/ui/label";
+import { CreditCard, Banknote } from "lucide-react";
+import { PaymentMethod } from "@/app/(main)/checkout/page";
+
+interface PaymentMethodSelectorProps {
+ selected: PaymentMethod;
+ onSelect: (method: PaymentMethod) => void;
+}
+
+export default function PaymentMethodSelector({ selected, onSelect }: PaymentMethodSelectorProps) {
+ return (
+ onSelect(value as PaymentMethod)}
+ className="space-y-4"
+ >
+ {/* СБП */}
+
+
+
+
+
+ Система быстрых платежей (СБП)
+
+
+ Быстрая оплата по QR-коду через мобильное приложение вашего банка
+
+
+
+
+
+
+
+ {/* Банковская карта */}
+
+
+
+
+
+ Банковская карта
+
+
+ Оплата картой Visa, MasterCard, МИР
+
+
+
+
+
+
+ Все платежи обрабатываются безопасно. Ваши данные защищены.
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/checkout/user-info-form.tsx b/frontend/components/checkout/user-info-form.tsx
new file mode 100644
index 0000000..8eefa5d
--- /dev/null
+++ b/frontend/components/checkout/user-info-form.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+
+interface UserInfo {
+ firstName: string;
+ lastName: string;
+ email: string;
+ phone: string;
+}
+
+interface UserInfoFormProps {
+ userInfo: UserInfo;
+ setUserInfo: (userInfo: UserInfo) => void;
+}
+
+export default function UserInfoForm({ userInfo, setUserInfo }: UserInfoFormProps) {
+ // Создаем локальное состояние для обработки формы
+ const [localInfo, setLocalInfo] = useState(userInfo);
+
+ // Обновляем родительский компонент при изменении локального состояния
+ useEffect(() => {
+ setUserInfo(localInfo);
+ }, [localInfo, setUserInfo]);
+
+ // Обработчик изменения полей формы
+ const handleChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ setLocalInfo(prev => ({ ...prev, [name]: value }));
+ };
+
+ // Функция проверки валидности телефона
+ const formatPhoneNumber = (value: string) => {
+ // Удаляем все нецифровые символы
+ const digits = value.replace(/\D/g, '');
+
+ // Форматируем номер в российском формате
+ if (digits.length <= 1) {
+ return digits;
+ } else if (digits.length <= 4) {
+ return `+7 (${digits.slice(1)}`;
+ } else if (digits.length <= 7) {
+ return `+7 (${digits.slice(1, 4)}) ${digits.slice(4)}`;
+ } else if (digits.length <= 9) {
+ return `+7 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7)}`;
+ } else {
+ return `+7 (${digits.slice(1, 4)}) ${digits.slice(4, 7)}-${digits.slice(7, 9)}-${digits.slice(9, 11)}`;
+ }
+ };
+
+ // Обработчик изменения телефона с форматированием
+ const handlePhoneChange = (e: React.ChangeEvent) => {
+ const formattedPhone = formatPhoneNumber(e.target.value);
+ setLocalInfo(prev => ({ ...prev, phone: formattedPhone }));
+ };
+
+ return (
+
+
+
+ {/* Email */}
+
+
+ Email *
+
+
+
+
+ {/* Телефон */}
+
+
+ Телефон *
+
+
+
+ Номер телефона для связи по вашему заказу
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/components/footer.tsx b/frontend/components/footer.tsx
deleted file mode 100644
index 19e3104..0000000
--- a/frontend/components/footer.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import Link from "next/link"
-import Image from "next/image"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Facebook, Instagram, Twitter, Mail, ArrowRight } from "lucide-react"
-import { motion } from "framer-motion"
-
-export default function Footer() {
- const fadeInUp = {
- initial: { opacity: 0, y: 20 },
- animate: { opacity: 1, y: 0 },
- transition: { duration: 0.5 }
- }
-
- const staggerContainer = {
- animate: { transition: { staggerChildren: 0.1 } }
- }
-
- return (
-
- )
-}
-
diff --git a/frontend/components/header.tsx b/frontend/components/header.tsx
deleted file mode 100644
index 2fd2ed6..0000000
--- a/frontend/components/header.tsx
+++ /dev/null
@@ -1,204 +0,0 @@
-"use client"
-
-import { useState, useEffect } from "react"
-import Link from "next/link"
-import Image from "next/image"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Search, ShoppingBag, User, Heart, Menu, X } from "lucide-react"
-import { usePathname } from "next/navigation"
-import { motion, AnimatePresence } from "framer-motion"
-
-export default function Header() {
- const [isScrolled, setIsScrolled] = useState(false)
- const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
- const [isSearchOpen, setIsSearchOpen] = useState(false)
- const pathname = usePathname()
-
- // Отслеживание скролла для изменения стиля хедера
- useEffect(() => {
- const handleScroll = () => {
- setIsScrolled(window.scrollY > 50)
- }
- window.addEventListener("scroll", handleScroll)
- return () => window.removeEventListener("scroll", handleScroll)
- }, [])
-
- // Закрытие мобильного меню при изменении маршрута
- useEffect(() => {
- setIsMobileMenuOpen(false)
- setIsSearchOpen(false)
- }, [pathname])
-
- // Навигационные ссылки
- const navLinks = [
- { name: "ГЛАВНАЯ", href: "/" },
- { name: "КАТАЛОГ", href: "/catalog" },
- { name: "КОЛЛЕКЦИИ", href: "/collections" },
- { name: "О НАС", href: "/about" },
- { name: "КОНТАКТЫ", href: "/contact" },
- ]
-
- return (
-
- )
-}
diff --git a/frontend/components/layout/footer.tsx b/frontend/components/layout/footer.tsx
deleted file mode 100644
index 66f0de5..0000000
--- a/frontend/components/layout/footer.tsx
+++ /dev/null
@@ -1,153 +0,0 @@
-import Link from "next/link"
-import Image from "next/image"
-import { Facebook, Instagram, Twitter, Mail, ArrowRight } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-
-export default function Footer() {
- return (
-
- )
-}
-
diff --git a/frontend/components/layout/header.tsx b/frontend/components/layout/header.tsx
deleted file mode 100644
index 392e9ee..0000000
--- a/frontend/components/layout/header.tsx
+++ /dev/null
@@ -1,146 +0,0 @@
-"use client"
-
-import { useState } from "react"
-import Link from "next/link"
-import { usePathname } from "next/navigation"
-import { Search, Heart, ShoppingBag, User, Menu, X } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
-
-export default function Header() {
- const [isMenuOpen, setIsMenuOpen] = useState(false)
- const pathname = usePathname()
-
- const navigation = [
- { name: "Каталог", href: "/catalog" },
- { name: "Коллекции", href: "/collections" },
- { name: "Новинки", href: "/new-arrivals" },
- { name: "О нас", href: "/about" },
- ]
-
- return (
-
- )
-}
-
diff --git a/frontend/components/layout/site-footer.tsx b/frontend/components/layout/site-footer.tsx
index cd5fd01..cedb913 100644
--- a/frontend/components/layout/site-footer.tsx
+++ b/frontend/components/layout/site-footer.tsx
@@ -1,33 +1,35 @@
import Link from "next/link"
-import { Instagram, Facebook, Twitter, Youtube } from "lucide-react"
+import { Instagram, Mail, ArrowRight } from "lucide-react"
+import { Input } from "@/components/ui/input"
+import { Button } from "@/components/ui/button"
export function SiteFooter() {
return (