diff --git a/src/api/product-service.ts b/src/api/product-service.ts index e01c0c6..3b9a820 100644 --- a/src/api/product-service.ts +++ b/src/api/product-service.ts @@ -8,23 +8,34 @@ import type { GetProductsRequestDTO, GetProductsResponseDTO, GetVariantResponseDTO, - ProductCreateRequestDTO, ProductCreateResponseDTO, + ProductCreateRequestDTO, + VariantCreateGrpcRequestDTO, + VariantCreateResponseDTO, + CategoryPropertyGroupResponse, VariantPropertyResponseDTO, } from "../types/products"; -import { apiCall } from "./utils"; +import { API_BASE_URL, apiCall } from "./utils"; const PRODUCT_SERVICE_URL = "http://localhost:8400"; const PRODUCT_API = `${PRODUCT_SERVICE_URL}/api/products`; const VARIANT_API = `${PRODUCT_SERVICE_URL}/api/variant`; const CATEGORY_API = `${PRODUCT_SERVICE_URL}/api/categories`; +const PROPERTIES_API = `${PRODUCT_SERVICE_URL}/api/properties`; +const PRODUCT_GRPC_API = `${API_BASE_URL}/api/products/grpc`; +const VARIANT_GRPC_API = `${API_BASE_URL}/api/variants/grpc`; export const createProduct = async ( productData: ProductCreateRequestDTO, ): Promise => { + const jwtToken = localStorage.getItem("token"); try { - const response = await apiCall(PRODUCT_API, { + const response = await apiCall(PRODUCT_GRPC_API, { method: "POST", + headers: { + Authorization: `Bearer ${jwtToken}`, + "Content-Type": "application/json", + }, body: JSON.stringify(productData), }); @@ -217,6 +228,8 @@ export const searchProducts = async ( throw new Error(`Failed to search products: ${response.status} ${response.statusText}`); } + console.log(response.json()); + return await response.json(); } catch (error) { console.error("API Error searching products:", error); @@ -236,7 +249,11 @@ export const getAllVariantDetails = async (variantId: string): Promise => { + try { + const url = new URL(PROPERTIES_API); + url.searchParams.set("categoryId", categoryId); + + const response = await apiCall(url.toString(), { + method: "GET", + }); + + if (!response.ok) { + throw new Error( + `Failed to get properties for category: ${response.status} ${response.statusText}`, + ); + } + + return await response.json(); + } catch (error) { + console.error("API Error fetching category properties:", error); + throw error; + } +}; + +export interface CreatePropertyGrpcRequestDTO { + categoryId: string; + name: string; + unit: string; + dataType: string; + role: "SELECTABLE" | "REQUIRED" | "INFO"; + defaultPropertyOptionValues: string[]; +} + +export interface CreatePropertyGrpcResponseDTO { + id: string; +} + +const PROPERTIES_GRPC_API = `${API_BASE_URL}/api/properties/grpc`; + +export const createPropertyGrpc = async ( + propertyData: CreatePropertyGrpcRequestDTO, +): Promise => { + const jwtToken = localStorage.getItem("token"); + + try { + const response = await apiCall(PROPERTIES_GRPC_API, { + method: "POST", + headers: { + Authorization: `Bearer ${jwtToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(propertyData), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to create property: ${response.status} ${response.statusText} - ${errorText}`, + ); + } + + return await response.json(); + } catch (error) { + console.error("API Error creating property:", error); + throw error; + } +}; + +export const createVariantGrpc = async ( + variantData: VariantCreateGrpcRequestDTO, +): Promise => { + const jwtToken = localStorage.getItem("token"); + console.log("createVariant", variantData); + + try { + const response = await apiCall(VARIANT_GRPC_API, { + method: "POST", + headers: { + Authorization: `Bearer ${jwtToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(variantData), + }); + + if (!response.ok) { + throw new Error(`Failed to create variant: ${response.status} ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error("API Error creating variant:", error); + throw error; + } +}; diff --git a/src/api/role-service.ts b/src/api/role-service.ts index 8cb9ae5..3545e17 100644 --- a/src/api/role-service.ts +++ b/src/api/role-service.ts @@ -1,7 +1,8 @@ import type { Role } from "../types/users.ts"; -import { API_BASE_URL, apiCall, jwtToken } from "./utils.ts"; +import { API_BASE_URL, apiCall } from "./utils.ts"; export const fetchRoles = async (): Promise => { + const jwtToken = localStorage.getItem("token"); if (!jwtToken) { throw new Error("Authorization token not found. Please log in."); } @@ -27,6 +28,7 @@ export const fetchRoles = async (): Promise => { }; export const addRole = async (role: Role): Promise => { + const jwtToken = localStorage.getItem("token"); if (!jwtToken) { throw new Error("Authorization token not found. Please log in."); } @@ -51,6 +53,7 @@ export const addRole = async (role: Role): Promise => { }; export const updateRole = async (role: Role): Promise => { + const jwtToken = localStorage.getItem("token"); if (!jwtToken) { throw new Error("Authorization token not found. Please log in."); } @@ -75,6 +78,7 @@ export const updateRole = async (role: Role): Promise => { }; export const deleteRole = async (roleName: string): Promise => { + const jwtToken = localStorage.getItem("token"); if (!jwtToken) { throw new Error("Authorization token not found. Please log in."); } @@ -98,6 +102,7 @@ export const deleteRole = async (roleName: string): Promise => { }; export const addUsersToRole = async (roleName: string, userIds: string[]): Promise => { + const jwtToken = localStorage.getItem("token"); if (!jwtToken) { throw new Error("Authorization token not found. Please log in."); } @@ -123,6 +128,7 @@ export const addUsersToRole = async (roleName: string, userIds: string[]): Promi }; export const deleteUsersFromRole = async (roleName: string, userIds: string[]): Promise => { + const jwtToken = localStorage.getItem("token"); if (!jwtToken) { throw new Error("Authorization token not found. Please log in."); } @@ -150,6 +156,7 @@ export const deleteUsersFromRole = async (roleName: string, userIds: string[]): }; export const fetchRolesPermissions = async (): Promise => { + const jwtToken = localStorage.getItem("token"); if (!jwtToken) { throw new Error("Authorization token not found. Please log in."); } diff --git a/src/api/utils.ts b/src/api/utils.ts index 09d9888..351dc52 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,7 +1,5 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8600"; -export const jwtToken = localStorage.getItem("token"); - const apiCall = async (url: string, options: RequestInit = {}) => { const defaultOptions: RequestInit = { credentials: "include", diff --git a/src/components/forms/CreateProductForm.tsx b/src/components/forms/CreateProductForm.tsx index 493c3fe..0e82a6b 100644 --- a/src/components/forms/CreateProductForm.tsx +++ b/src/components/forms/CreateProductForm.tsx @@ -12,7 +12,6 @@ const initialProductValues: ProductFormValues = { approximatePrice: 0, deliveryPrice: 0, description: "", - info: {}, }; const productValidationSchema = Yup.object({ @@ -122,26 +121,6 @@ const CreateProductForm: React.FC = ({ onSubmit }) => { placeholder="Detailed product description" /> - - - Dodatkowe Info (JSON) - - - - Enter additional product attributes as a valid JSON object. - - diff --git a/src/hooks/useProductPage.ts b/src/hooks/useProductPage.ts index 3c3dbed..b885152 100644 --- a/src/hooks/useProductPage.ts +++ b/src/hooks/useProductPage.ts @@ -33,23 +33,18 @@ export const useProductPage = () => { (propertyName: string): string[] => { if (!allVariants.length) return []; - const currentSelections = { ...selectedProperties }; - delete currentSelections[propertyName]; - + // Zwracamy WSZYSTKIE możliwe wartości dla danej właściwości ze WSZYSTKICH wariantów + // niezależnie od aktualnych wyborów, aby umożliwić przejście między wszystkimi wariantami const availableValues = new Set(); allVariants.forEach((variant) => { - const matchesCurrentSelection = Object.keys(currentSelections).every( - (key) => variant[key] === currentSelections[key], - ); - - if (matchesCurrentSelection && variant[propertyName]) { + if (variant[propertyName]) { availableValues.add(variant[propertyName]); } }); return Array.from(availableValues); }, - [allVariants, selectedProperties], + [allVariants], ); const findVariantId = useCallback( @@ -140,19 +135,33 @@ export const useProductPage = () => { [propertyName]: value, }; - selectablePropertyNames.forEach((propName) => { - if (propName !== propertyName) { - const availableValues = getAvailableValues(propName); - if (availableValues.length > 0 && !availableValues.includes(newSelections[propName])) { - delete newSelections[propName]; + // Najpierw próbujemy znaleźć dokładne dopasowanie + let newVariantId = findVariantId(newSelections); + + // Jeśli nie ma dokładnego dopasowania, szukamy wariantu który ma wybraną wartość dla zmienionej właściwości + // i zachowuje inne wybrane wartości (jeśli są dostępne) + if (!newVariantId) { + const matchingVariant = allVariants.find((variant) => { + // Sprawdzamy czy wariant ma wybraną wartość dla zmienionej właściwości + if (variant[propertyName] !== value) { + return false; } - } - }); + // Sprawdzamy czy wariant ma wszystkie inne wybrane wartości (jeśli są) + return Object.keys(newSelections).every( + (key) => !newSelections[key] || variant[key] === newSelections[key], + ); + }); + newVariantId = matchingVariant?.variantId || null; + } - setSelectedProperties(newSelections); + // Jeśli nadal nie znaleźliśmy wariantu, szukamy dowolnego wariantu z wybraną wartością dla zmienionej właściwości + if (!newVariantId) { + const matchingVariant = allVariants.find((variant) => variant[propertyName] === value); + newVariantId = matchingVariant?.variantId || null; + } - const newVariantId = findVariantId(newSelections); - if (!(newVariantId && newVariantId !== variantId)) { + // Jeśli nie znaleźliśmy wariantu, nie aktualizujemy stanu + if (!newVariantId || newVariantId === variantId) { return; } @@ -167,6 +176,18 @@ export const useProductPage = () => { }); } + // Aktualizujemy wybrane właściwości na podstawie znalezionego wariantu + // To zapewnia, że wyświetlane wartości są zawsze zgodne z aktualnym wariantem + if (newVariantData) { + const updatedSelections: SelectedProperties = {}; + selectablePropertyNames.forEach((propName) => { + if (newVariantData[propName]) { + updatedSelections[propName] = newVariantData[propName]; + } + }); + setSelectedProperties(updatedSelections); + } + navigate(`/product/${newVariantId}`, { replace: true, preventScrollReset: true }); loadVariantDetails(newVariantId, true); @@ -175,7 +196,6 @@ export const useProductPage = () => { [ selectedProperties, selectablePropertyNames, - getAvailableValues, findVariantId, variantId, allVariants, diff --git a/src/pages/ProductPage.tsx b/src/pages/ProductPage.tsx index 608a1ac..8a15a68 100644 --- a/src/pages/ProductPage.tsx +++ b/src/pages/ProductPage.tsx @@ -104,9 +104,10 @@ const ProductPage: React.FC = () => { }) : ["https://via.placeholder.com/600x600?text=No+Image"]; + // Zawsze pokazuj REQUIRED i INFO, SELECTABLE tylko gdy showMoreParams jest true const displayedParams = showMoreParams ? [...selectableProperties, ...requiredProperties, ...infoProperties] - : [...requiredProperties, ...selectableProperties]; + : [...requiredProperties, ...infoProperties]; const descriptionPoints = variant?.description ? variant.description.split("\n").filter((line) => line.trim()) @@ -250,7 +251,7 @@ const ProductPage: React.FC = () => { ); })} - {(selectableProperties.length > 0 || infoProperties.length > 0) && ( + {selectableProperties.length > 0 && ( + + + + + {createdProductId && ( + <> + + + + {loadingProperties && ( + + + Loading properties... + + )} + {!loadingProperties && properties && ( + + {renderPropertyGroup( + "Selectable (always required)", + groupedProperties.selectable, + true, + )} + {renderPropertyGroup("Required", groupedProperties.required, true)} + {renderPropertyGroup("Informational", groupedProperties.info, false)} + + )} + + + + + + + + {variantsCreated.map((variant) => { + const firstImage = variant.variantImages[0]; + const propertyLabels = getFirstTwoPropertyValues(variant); + return ( + + + + {firstImage ? ( + Variant { + const target = e.target as HTMLImageElement; + target.style.display = "none"; + if (target.parentElement) { + target.parentElement.innerHTML = + 'No image'; + } + }} + /> + ) : ( + + No image + + )} + + + + {propertyLabels.map((label, idx) => ( + + {label} + + ))} + + {variant.price} PLN + + + + + + ); + })} + + setVariantModalOpen(true)} + > + + + + Add variant + + + + + + + + + )} + + + { + setVariantModalOpen(false); + resetVariantForm(); + }} + maxWidth="md" + fullWidth + > + Add New Variant + + + + + + setVariantForm((prev) => ({ ...prev, price: Number(e.target.value) })) + } + /> + + + + setVariantForm((prev) => ({ ...prev, stockQuantity: Number(e.target.value) })) + } + /> + + + + setVariantForm((prev) => ({ ...prev, description: e.target.value })) + } + /> + + + setImageInput(e.target.value)} + placeholder="https://example.com/image1.jpg https://example.com/image2.jpg or https://example.com/image1.jpg, https://example.com/image2.jpg" + helperText="You can paste multiple URLs at once - each on a new line or separated by comma" + /> + + {variantForm.variantImages.length > 0 && ( + + + {variantForm.variantImages.map((img, idx) => ( + 30 ? `${img.substring(0, 30)}...` : img} + onDelete={() => + setVariantForm((prev) => ({ + ...prev, + variantImages: prev.variantImages.filter((_, i) => i !== idx), + })) + } + /> + ))} + + + )} + + + {properties && ( + + {renderPropertyGroup( + "Selectable (always required)", + groupedProperties.selectable, + true, + )} + {renderPropertyGroup("Required", groupedProperties.required, true)} + {renderPropertyGroup("Informational", groupedProperties.info, false)} + + )} + + + + + + Additional Properties (INFO) + + + + {customProperties.map((prop) => ( + + + + + + handleUpdateCustomProperty(prop.id, "name", e.target.value) + } + placeholder="e.g. Material, Country of Origin" + /> + + + + handleUpdateCustomProperty(prop.id, "value", e.target.value) + } + placeholder="Enter value" + /> + + + handleRemoveCustomProperty(prop.id)} + aria-label="Remove property" + > + + + + + + + ))} + {customProperties.length === 0 && ( + + No additional properties. Click "Add Property" to add a new one. + + )} + + + + + + + + + ); diff --git a/src/types/products.ts b/src/types/products.ts index fc83a73..8d968d7 100644 --- a/src/types/products.ts +++ b/src/types/products.ts @@ -14,7 +14,6 @@ export interface ProductCreateRequestDTO { approximatePrice: number; deliveryPrice: number; description: string; - info: Record; } export interface ProductCreateResponseDTO { @@ -30,6 +29,20 @@ export interface VariantCreateRequestDTO { description: string; } +export interface VariantPropertyValueRequestDTO { + propertyId: string; + displayText: string; +} + +export interface VariantCreateGrpcRequestDTO { + productId: string; + price: number; + stockQuantity: number; + description: string; + variantImages: string[]; + variantPropertyValues: VariantPropertyValueRequestDTO[]; +} + export interface VariantCreateResponseDTO { id: string; } @@ -89,3 +102,38 @@ export interface VariantPropertyResponseDTO { } export type PropertyDataType = "TEXT" | "NUMBER" | "BOOLEAN" | "DATE"; + +export interface PropertyOptionDTO { + id: string; + displayText: string; + isDefaultPropertyOption?: boolean; +} + +export interface DefaultPropertyOptionDTO { + id: string; + propertyId: string; + propertyDataType: PropertyDataType; + valueText: string | null; + valueDecimal: number | null; + valueBoolean: boolean | null; + valueDate: string | null; + displayText: string; +} + +export interface CategoryPropertyDTO { + id: string; + categoryId?: string; + name: string; + dataType: PropertyDataType; + description?: string; + hasDefaultOptions?: boolean; + role?: "SELECTABLE" | "REQUIRED" | "INFO"; + propertyOptions?: PropertyOptionDTO[]; + defaultPropertyOptions?: DefaultPropertyOptionDTO[]; +} + +export interface CategoryPropertyGroupResponse { + selectable?: CategoryPropertyDTO[]; + required?: CategoryPropertyDTO[]; + info?: CategoryPropertyDTO[]; +}