feat: product images upload and display

This commit is contained in:
2025-10-20 16:13:05 +04:00
parent 8cc11bca67
commit 82f08b4f83
12 changed files with 380 additions and 226 deletions

10
fix-zod.ts Normal file
View File

@ -0,0 +1,10 @@
import * as fs from "fs";
const path = "src/lib/client/zod.gen.ts";
let content = fs.readFileSync(path, "utf8");
// Replace only for the upload schema
content = content.replace("upload_file: z.string", "upload_file: z.any");
fs.writeFileSync(path, content);
console.log("✅ Fixed zod schema for upload_file");

View File

@ -7,7 +7,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"generate-client": "openapi-ts && prettier --write ./src/lib/client/**/*.ts && git add ./src/lib/client",
"generate-client": "openapi-ts && prettier --write ./src/lib/client/**/*.ts && git add ./src/lib/client & sudo npx tsc ./fix-zod.ts && mv -f ./fix-zod.js ./fix-zod.cjs && sudo node ./fix-zod.cjs",
"generate-modules": "sudo npx tsc ./src/modules/modulesFileGen/modulesFileGen.ts && mv -f ./src/modules/modulesFileGen/modulesFileGen.js ./src/modules/modulesFileGen/modulesFileGen.cjs && sudo node ./src/modules/modulesFileGen/modulesFileGen.cjs"
},
"dependencies": {

View File

@ -47,7 +47,7 @@ const ServicesKitEditorModal = ({
{...form.getInputProps("name")}
/>
<ServiceTypeSegmentedControl
value={form.values.serviceType?.toString()}
value={form.values.serviceType}
onChange={tab => {
form.setFieldValue("serviceType", Number(tab));
form.setFieldValue("services", []);

View File

@ -60,7 +60,6 @@ import {
getMarketplaces,
getProductBarcodePdf,
getProducts,
getProject,
getProjects,
getServiceCategories,
getServices,
@ -85,6 +84,7 @@ import {
updateServiceCategory,
updateServicesKit,
updateStatus,
uploadProductImage,
type Options,
} from "../sdk.gen";
import type {
@ -215,7 +215,6 @@ import type {
GetProductsData,
GetProductsError,
GetProductsResponse2,
GetProjectData,
GetProjectsData,
GetServiceCategoriesData,
GetServicesData,
@ -276,6 +275,9 @@ import type {
UpdateStatusData,
UpdateStatusError,
UpdateStatusResponse2,
UploadProductImageData,
UploadProductImageError,
UploadProductImageResponse,
} from "../types.gen";
export type QueryKey<TOptions extends Options> = [
@ -2188,6 +2190,57 @@ export const updateProductMutation = (
return mutationOptions;
};
export const uploadProductImageQueryKey = (
options: Options<UploadProductImageData>
) => createQueryKey("uploadProductImage", options);
/**
* Upload Product Image
*/
export const uploadProductImageOptions = (
options: Options<UploadProductImageData>
) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await uploadProductImage({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: uploadProductImageQueryKey(options),
});
};
/**
* Upload Product Image
*/
export const uploadProductImageMutation = (
options?: Partial<Options<UploadProductImageData>>
): UseMutationOptions<
UploadProductImageResponse,
AxiosError<UploadProductImageError>,
Options<UploadProductImageData>
> => {
const mutationOptions: UseMutationOptions<
UploadProductImageResponse,
AxiosError<UploadProductImageError>,
Options<UploadProductImageData>
> = {
mutationFn: async localOptions => {
const { data } = await uploadProductImage({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const getProductBarcodePdfQueryKey = (
options: Options<GetProductBarcodePdfData>
) => createQueryKey("getProductBarcodePdf", options);
@ -2716,27 +2769,6 @@ export const deleteProjectMutation = (
return mutationOptions;
};
export const getProjectQueryKey = (options: Options<GetProjectData>) =>
createQueryKey("getProject", options);
/**
* Get Project
*/
export const getProjectOptions = (options: Options<GetProjectData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getProject({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: getProjectQueryKey(options),
});
};
/**
* Update Project
*/

View File

@ -1,6 +1,11 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Client, Options as ClientOptions, TDataShape } from "./client";
import {
formDataBodySerializer,
type Client,
type Options as ClientOptions,
type TDataShape,
} from "./client";
import { client as _heyApiClient } from "./client.gen";
import type {
AddKitToDealData,
@ -147,9 +152,6 @@ import type {
GetProductsData,
GetProductsErrors,
GetProductsResponses,
GetProjectData,
GetProjectErrors,
GetProjectResponses,
GetProjectsData,
GetProjectsResponses,
GetServiceCategoriesData,
@ -218,6 +220,9 @@ import type {
UpdateStatusData,
UpdateStatusErrors,
UpdateStatusResponses,
UploadProductImageData,
UploadProductImageErrors,
UploadProductImageResponses,
} from "./types.gen";
import {
zAddKitToDealData,
@ -320,8 +325,6 @@ import {
zGetProductBarcodePdfResponse2,
zGetProductsData,
zGetProductsResponse2,
zGetProjectData,
zGetProjectResponse2,
zGetProjectsData,
zGetProjectsResponse2,
zGetServiceCategoriesData,
@ -370,6 +373,8 @@ import {
zUpdateServicesKitResponse2,
zUpdateStatusData,
zUpdateStatusResponse2,
zUploadProductImageData,
zUploadProductImageResponse,
} from "./zod.gen";
export type Options<
@ -1695,6 +1700,34 @@ export const updateProduct = <ThrowOnError extends boolean = false>(
});
};
/**
* Upload Product Image
*/
export const uploadProductImage = <ThrowOnError extends boolean = false>(
options: Options<UploadProductImageData, ThrowOnError>
) => {
return (options.client ?? _heyApiClient).post<
UploadProductImageResponses,
UploadProductImageErrors,
ThrowOnError
>({
...formDataBodySerializer,
requestValidator: async data => {
return await zUploadProductImageData.parseAsync(data);
},
responseType: "json",
responseValidator: async data => {
return await zUploadProductImageResponse.parseAsync(data);
},
url: "/crm/v1/fulfillment-base/product/images/upload/{productId}",
...options,
headers: {
"Content-Type": null,
...options.headers,
},
});
};
/**
* Get Product Barcode Pdf
*/
@ -2095,29 +2128,6 @@ export const deleteProject = <ThrowOnError extends boolean = false>(
});
};
/**
* Get Project
*/
export const getProject = <ThrowOnError extends boolean = false>(
options: Options<GetProjectData, ThrowOnError>
) => {
return (options.client ?? _heyApiClient).get<
GetProjectResponses,
GetProjectErrors,
ThrowOnError
>({
requestValidator: async data => {
return await zGetProjectData.parseAsync(data);
},
responseType: "json",
responseValidator: async data => {
return await zGetProjectResponse2.parseAsync(data);
},
url: "/crm/v1/project/{pk}",
...options,
});
};
/**
* Update Project
*/

View File

@ -103,6 +103,16 @@ export type BoardSchema = {
projectId: number;
};
/**
* Body_upload_product_image
*/
export type BodyUploadProductImage = {
/**
* Upload File
*/
upload_file: Blob | File;
};
/**
* BuiltInModuleSchema
*/
@ -630,6 +640,14 @@ export type CreateProductSchema = {
* Barcodes
*/
barcodes: Array<string>;
/**
* Imageurl
*/
imageUrl?: string | null;
/**
* Images
*/
images?: Array<ProductImageSchema> | null;
};
/**
@ -1400,13 +1418,6 @@ export type GetProductsResponse = {
paginationInfo: PaginationInfoSchema;
};
/**
* GetProjectResponse
*/
export type GetProjectResponse = {
entity: ProjectSchema;
};
/**
* GetProjectsResponse
*/
@ -1593,6 +1604,14 @@ export type ProductSchema = {
* Barcodes
*/
barcodes: Array<string>;
/**
* Imageurl
*/
imageUrl?: string | null;
/**
* Images
*/
images?: Array<ProductImageSchema> | null;
/**
* Id
*/
@ -1655,6 +1674,20 @@ export type ProductServicesDuplicateResponse = {
message: string;
};
/**
* ProductUploadImageResponse
*/
export type ProductUploadImageResponse = {
/**
* Message
*/
message: string;
/**
* Imageurl
*/
imageUrl?: string | null;
};
/**
* ProjectSchema
*/
@ -4036,6 +4069,38 @@ export type UpdateProductResponses = {
export type UpdateProductResponse2 =
UpdateProductResponses[keyof UpdateProductResponses];
export type UploadProductImageData = {
body: BodyUploadProductImage;
path: {
/**
* Productid
*/
productId: number;
};
query?: never;
url: "/crm/v1/fulfillment-base/product/images/upload/{productId}";
};
export type UploadProductImageErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UploadProductImageError =
UploadProductImageErrors[keyof UploadProductImageErrors];
export type UploadProductImageResponses = {
/**
* Successful Response
*/
200: ProductUploadImageResponse;
};
export type UploadProductImageResponse =
UploadProductImageResponses[keyof UploadProductImageResponses];
export type GetProductBarcodePdfData = {
body: GetProductBarcodePdfRequest;
path?: never;
@ -4458,37 +4523,6 @@ export type DeleteProjectResponses = {
export type DeleteProjectResponse2 =
DeleteProjectResponses[keyof DeleteProjectResponses];
export type GetProjectData = {
body?: never;
path: {
/**
* Pk
*/
pk: number;
};
query?: never;
url: "/crm/v1/project/{pk}";
};
export type GetProjectErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetProjectError = GetProjectErrors[keyof GetProjectErrors];
export type GetProjectResponses = {
/**
* Successful Response
*/
200: GetProjectResponse;
};
export type GetProjectResponse2 =
GetProjectResponses[keyof GetProjectResponses];
export type UpdateProjectData = {
body: UpdateProjectRequest;
path: {

View File

@ -51,6 +51,13 @@ export const zBoardSchema = z.object({
projectId: z.int(),
});
/**
* Body_upload_product_image
*/
export const zBodyUploadProductImage = z.object({
upload_file: z.any(),
});
/**
* BuiltInModuleTabSchema
*/
@ -234,6 +241,15 @@ export const zCreateDealProductRequest = z.object({
entity: zCreateDealProductSchema,
});
/**
* ProductImageSchema
*/
export const zProductImageSchema = z.object({
id: z.int(),
productId: z.int(),
imageUrl: z.string(),
});
/**
* ProductSchema
*/
@ -249,6 +265,8 @@ export const zProductSchema = z.object({
size: z.union([z.string(), z.null()]),
additionalInfo: z.union([z.string(), z.null()]),
barcodes: z.array(z.string()),
imageUrl: z.optional(z.union([z.string(), z.null()])),
images: z.optional(z.union([z.array(zProductImageSchema), z.null()])),
id: z.int(),
barcodeTemplate: zBarcodeTemplateSchema,
});
@ -509,6 +527,8 @@ export const zCreateProductSchema = z.object({
size: z.union([z.string(), z.null()]),
additionalInfo: z.union([z.string(), z.null()]),
barcodes: z.array(z.string()),
imageUrl: z.optional(z.union([z.string(), z.null()])),
images: z.optional(z.union([z.array(zProductImageSchema), z.null()])),
});
/**
@ -957,13 +977,6 @@ export const zGetProductsResponse = z.object({
paginationInfo: zPaginationInfoSchema,
});
/**
* GetProjectResponse
*/
export const zGetProjectResponse = z.object({
entity: zProjectSchema,
});
/**
* GetProjectsResponse
*/
@ -1042,15 +1055,6 @@ export const zHttpValidationError = z.object({
detail: z.optional(z.array(zValidationError)),
});
/**
* ProductImageSchema
*/
export const zProductImageSchema = z.object({
id: z.int(),
productId: z.int(),
imageUrl: z.string(),
});
/**
* ProductServicesDuplicateRequest
*/
@ -1067,6 +1071,14 @@ export const zProductServicesDuplicateResponse = z.object({
message: z.string(),
});
/**
* ProductUploadImageResponse
*/
export const zProductUploadImageResponse = z.object({
message: z.string(),
imageUrl: z.optional(z.union([z.string(), z.null()])),
});
export const zSortDir = z.enum(["asc", "desc"]);
/**
@ -2146,6 +2158,19 @@ export const zUpdateProductData = z.object({
*/
export const zUpdateProductResponse2 = zUpdateProductResponse;
export const zUploadProductImageData = z.object({
body: zBodyUploadProductImage,
path: z.object({
productId: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zUploadProductImageResponse = zProductUploadImageResponse;
export const zGetProductBarcodePdfData = z.object({
body: zGetProductBarcodePdfRequest,
path: z.optional(z.never()),
@ -2336,19 +2361,6 @@ export const zDeleteProjectData = z.object({
*/
export const zDeleteProjectResponse2 = zDeleteProjectResponse;
export const zGetProjectData = z.object({
body: z.optional(z.never()),
path: z.object({
pk: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zGetProjectResponse2 = zGetProjectResponse;
export const zUpdateProjectData = z.object({
body: zUpdateProjectRequest,
path: z.object({

View File

@ -25,14 +25,16 @@ export type BaseFormProps<T> = {
onClose: () => void;
closeOnSubmit?: boolean;
children: ReactNode;
actionsEnabled?: boolean;
};
type Props<TCreate, TUpdate, TEntity> = BaseFormProps<TEntity> &
CreateEditFormProps<TCreate, TUpdate, TEntity>;
const BaseFormModal = <TCreate, TUpdate = TCreate, TEntity = TUpdate>(
props: Props<TCreate, TUpdate, TEntity>
) => {
const BaseFormModal = <TCreate, TUpdate = TCreate, TEntity = TUpdate>({
actionsEnabled = true,
...props
}: Props<TCreate, TUpdate, TEntity>) => {
const { closeOnSubmit = false } = props;
const onSubmit = (values: Partial<TEntity>) => {
@ -54,7 +56,7 @@ const BaseFormModal = <TCreate, TUpdate = TCreate, TEntity = TUpdate>(
gap={"xs"}
direction={"column"}>
{props.children}
<BaseFormModalActions {...props} />
{actionsEnabled && <BaseFormModalActions {...props} />}
</Flex>
</form>
);

View File

@ -1,6 +1,7 @@
import { FC } from "react";
import { DropzoneProps, FileWithPath } from "@mantine/dropzone";
import ImageDropzone from "@/components/ui/ImageDropzone/ImageDropzone";
import { uploadProductImage } from "@/lib/client";
import { notifications } from "@/lib/notifications";
import BaseFormInputProps from "@/utils/baseFormInputProps";
import useImageDropzone from "./useImageDropzone";
@ -31,30 +32,31 @@ const ProductImageDropzone: FC<Props> = ({
setIsLoading(true);
// TODO SEND REQUEST
uploadProductImage({
path: {
productId,
},
body: {
upload_file: file,
},
})
.then(({ data }) => {
notifications.success({ message: data?.message });
setIsLoading(false);
// ProductService.uploadProductImage({
// productId,
// formData: {
// upload_file: file,
// },
// })
// .then(({ ok, message, imageUrl }) => {
// notifications.guess(ok, { message });
// setIsLoading(false);
//
// if (!ok || !imageUrl) {
// setShowDropzone(true);
// return;
// }
// imageUrlInputProps?.onChange(imageUrl);
// setShowDropzone(false);
// })
// .catch(error => {
// notifications.error({ message: error.toString() });
// setShowDropzone(true);
// setIsLoading(false);
// });
if (!data?.imageUrl) {
setShowDropzone(true);
return;
}
imageUrlInputProps?.onChange(data?.imageUrl);
setShowDropzone(false);
})
.catch(err => {
console.log(err);
notifications.error({ message: err.toString() });
setShowDropzone(true);
setIsLoading(false);
});
};
return (

View File

@ -107,12 +107,12 @@ const ProductView: FC<Props> = ({ dealProduct }) => {
<Stack
flex={2}
gap={"sm"}>
{!dealProduct.product && (
{dealProduct.product?.imageUrl && (
<Image
flex={1}
radius={"md"}
fit={"cover"}
// src={dealProduct.product.imageUrl}
src={dealProduct.product.imageUrl}
/>
)}
<Title order={3}>{dealProduct.product.name}</Title>

View File

@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { Fieldset, Flex, Stack, TagsInput, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { ContextModalProps } from "@mantine/modals";
@ -14,6 +15,9 @@ import BaseFormModal, {
CreateEditFormProps,
} from "@/modals/base/BaseFormModal/BaseFormModal";
import ProductImageDropzone from "@/modules/dealModularEditorTabs/FulfillmentBase/desktop/components/ProductImageDropzone/ProductImageDropzone";
import ProductEditorSegmentedControl, {
ProductEditorTab,
} from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/modals/ProductEditorModal/components/ProductEditorSegmentedControl";
import BaseFormInputProps from "@/utils/baseFormInputProps";
type Props = CreateEditFormProps<
@ -35,6 +39,9 @@ const ProductEditorModal = ({
id,
innerProps,
}: ContextModalProps<Props>) => {
const [editorTab, setEditorTab] = useState<ProductEditorTab>(
ProductEditorTab.CHARACTERISTICS
);
const isEditing = "entity" in innerProps;
const initialValues: ProductForm = isEditing
@ -63,15 +70,8 @@ const ProductEditorModal = ({
},
});
return (
<BaseFormModal
{...innerProps}
form={form}
closeOnSubmit
onClose={() => context.closeContextModal(id)}>
<Flex
gap={"xs"}
direction={"column"}>
const characteristicsTab = (
<>
<Fieldset legend={"Основные характеристики"}>
<Stack gap={"xs"}>
<TextInput
@ -141,16 +141,37 @@ const ProductEditorModal = ({
/>
</Stack>
</Fieldset>
{isEditing && (
</>
);
const imageTab = isEditing && (
<ProductImageDropzone
imageUrlInputProps={
form.getInputProps(
"imageUrl"
) as BaseFormInputProps<string>
form.getInputProps("imageUrl") as BaseFormInputProps<string>
}
productId={innerProps.entity.id}
/>
);
return (
<BaseFormModal
{...innerProps}
form={form}
closeOnSubmit
actionsEnabled={editorTab === ProductEditorTab.CHARACTERISTICS}
onClose={() => context.closeContextModal(id)}>
<Flex
gap={"xs"}
direction={"column"}>
{isEditing && (
<ProductEditorSegmentedControl
value={editorTab}
onChange={setEditorTab}
/>
)}
{editorTab === ProductEditorTab.CHARACTERISTICS
? characteristicsTab
: imageTab}
</Flex>
</BaseFormModal>
);

View File

@ -0,0 +1,31 @@
import { FC } from "react";
import BaseSegmentedControl, {
BaseSegmentedControlProps,
} from "@/components/ui/BaseSegmentedControl/BaseSegmentedControl";
export enum ProductEditorTab {
CHARACTERISTICS,
IMAGES,
}
type Props = Omit<BaseSegmentedControlProps<ProductEditorTab>, "data">;
const data = [
{
label: "Характеристики",
value: ProductEditorTab.CHARACTERISTICS,
},
{
label: "Изображение",
value: ProductEditorTab.IMAGES,
},
];
const ProductEditorSegmentedControl: FC<Props> = props => (
<BaseSegmentedControl
data={data}
{...props}
/>
);
export default ProductEditorSegmentedControl;