├── .gitignore ├── README.md ├── package.json ├── src ├── admin │ ├── VariantsImages │ │ ├── VariantsImagesMediaForm.tsx │ │ ├── VariantsImagesModal.tsx │ │ ├── components │ │ │ └── FileUploadField.tsx │ │ └── utils │ │ │ ├── images.ts │ │ │ └── nestedForm.ts │ └── widgets │ │ └── VariantsImagesWidget.tsx ├── api │ └── index.ts ├── index.d.ts ├── loaders │ ├── product-variant.ts │ └── product.ts ├── migrations │ ├── 1688410506235-product_variant_images.ts │ └── 1696434674696-product_variant_thumbnail.ts ├── models │ └── product-variant.ts ├── services │ └── product-variant.ts └── validators │ └── related-products.ts ├── tsconfig.admin.json ├── tsconfig.json ├── tsconfig.server.json ├── tsconfig.spec.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /build 3 | .env 4 | .DS_Store 5 | .cache 6 | /uploads 7 | /node_modules 8 | yarn-error.log 9 | /.idea 10 | compose-dev.yaml -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | As Medusa is not stable and looks like the Medusa team doesn't care about bug fixing I'm archiving this repo. Feel free to fork it if you want. But I would recommend you to have a look at [Vendure](https://vendure.io/) as it's more stable and this functionality is out of the box. 2 | # Medusa-Plugin-Variant-Images 3 | 4 | Adding possibility to add images to the product variants. 5 | 6 | # Getting started 7 | 8 | Installation 9 | 10 | ```bash 11 | yarn add medusa-plugin-variant-images 12 | ``` 13 | 14 | # Usage 15 | 16 | ## Configuration 17 | 18 | This plugin requires min version of packages: 19 | 20 | ``` 21 | "@medusajs/medusa": "^1.14.0", 22 | "@medusajs/admin": "^7.0.0" 23 | "medusa-react": "^9.0.4", 24 | "@medusajs/ui": "^1.0.0", 25 | "@medusajs/icons": "^1.0.0", 26 | ``` 27 | 28 | ### Add to medusa-config.js 29 | 30 | add to your plugins list 31 | 32 | ``` 33 | ///...other plugins 34 | { 35 | resolve: 'medusa-plugin-variant-images', 36 | options: { 37 | enableUI: true, 38 | }, 39 | }, 40 | 41 | ``` 42 | 43 | ### Update database schema 44 | 45 | Run the following command from the root of the project to udpate database with a new table required for storing product variant 46 | 47 | ``` 48 | npx medusa migrations run 49 | ``` 50 | 51 | ### How to get images from variant 52 | 53 | After enabling this plugin, each variant will contains `images` and `thumbnail` fields. 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "medusa-plugin-variant-images", 3 | "version": "1.0.5", 4 | "description": "Plugin for adding images and thumbnail to variants", 5 | "main": "dist/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/andriinuts/medusa-plugin-variant-images" 9 | }, 10 | "files": [ 11 | "dist" 12 | ], 13 | "author": "Andrii Nutskovskyi ", 14 | "license": "MIT", 15 | "scripts": { 16 | "prepare": "cross-env NODE_ENV=production yarn run build", 17 | "test": "jest --passWithNoTests src", 18 | "build": "tsc -p ./tsconfig.server.json && medusa-admin bundle", 19 | "watch": "tsc --watch" 20 | }, 21 | "devDependencies": { 22 | "@medusajs/medusa": "^1.14.0", 23 | "@medusajs/ui": "^1.0.0", 24 | "@medusajs/icons": "^1.0.0", 25 | "medusa-react": "^9.0.4", 26 | "@medusajs/admin": "^7.0.0", 27 | "@types/stripe": "^8.0.417", 28 | "awilix": "^8.0.1", 29 | "cross-env": "^5.2.1", 30 | "jest": "^25.5.4", 31 | "typescript": "^4.9.5" 32 | }, 33 | "peerDependenciesMeta": { 34 | "medusa-react": { 35 | "optional": true 36 | } 37 | }, 38 | "peerDependencies": { 39 | "@medusajs/medusa": "^1.14.0", 40 | "@medusajs/ui": "^1.0.0", 41 | "@medusajs/icons": "^1.0.0", 42 | "medusa-react": "^9.0.4", 43 | "@medusajs/admin": "^7.0.0" 44 | }, 45 | "dependencies": { 46 | "body-parser": "^1.19.0", 47 | "express": "^4.17.1", 48 | "medusa-core-utils": "^1.2.0", 49 | "typeorm": "^0.3.17" 50 | }, 51 | "keywords": [ 52 | "medusa-plugin", 53 | "medusa-plugin-variant", 54 | "medusa-plugin-images", 55 | "medusa-plugin-variant-images" 56 | ] 57 | } 58 | -------------------------------------------------------------------------------- /src/admin/VariantsImages/VariantsImagesMediaForm.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { Controller, FieldArrayWithId, useFieldArray } from 'react-hook-form'; 3 | import { NestedForm } from './utils/nestedForm'; 4 | import { FormImage } from './utils/images'; 5 | import FileUploadField from './components/FileUploadField'; 6 | import { CheckCircleSolid } from '@medusajs/icons'; 7 | import { useRef } from 'react'; 8 | 9 | type ImageType = { selected: boolean } & FormImage; 10 | 11 | export type MediaFormType = { 12 | images: ImageType[]; 13 | }; 14 | 15 | type Props = { 16 | form: NestedForm; 17 | type: 'thumbnail' | 'media'; 18 | }; 19 | 20 | const VariantsImagesMediaForm = ({ form, type }: Props) => { 21 | const { control, path, setValue } = form; 22 | 23 | const singleSelection = type === 'thumbnail'; 24 | const { fields, append } = useFieldArray({ 25 | control: control, 26 | name: path('images'), 27 | }); 28 | 29 | const prevSelectedImage = useRef( 30 | fields?.findIndex((field) => field.selected) 31 | ); 32 | 33 | const handleFilesChosen = (files: File[]) => { 34 | if (files.length) { 35 | const toAppend = files.map((file) => ({ 36 | url: URL.createObjectURL(file), 37 | name: file.name, 38 | size: file.size, 39 | nativeFile: file, 40 | selected: false, 41 | })); 42 | 43 | append(toAppend); 44 | } 45 | }; 46 | 47 | const handleImageSelected = (index: number) => { 48 | if (prevSelectedImage.current !== undefined && singleSelection) { 49 | setValue(path(`images.${prevSelectedImage.current}.selected`), false); 50 | } 51 | prevSelectedImage.current = index; 52 | }; 53 | 54 | return ( 55 |
56 |
57 |
58 | 65 |
66 |
67 | {fields.length > 0 && ( 68 |
69 |
70 |

Uploads

71 |

72 | {type === 'thumbnail' ? ( 73 | Select an image to use as variant thumbnail. 74 | ) : ( 75 | Select images to use as variant images. 76 | )} 77 |

78 |
79 |
80 | {fields.map((field, index) => { 81 | return ( 82 | 89 | ); 90 | })} 91 |
92 |
93 | )} 94 |
95 | ); 96 | }; 97 | 98 | type ImageProps = { 99 | image: FieldArrayWithId; 100 | index: number; 101 | form: NestedForm; 102 | onSelected: (index: number) => void; 103 | }; 104 | 105 | const Image = ({ image, index, form, onSelected }: ImageProps) => { 106 | const { control, path } = form; 107 | 108 | return ( 109 | { 113 | return ( 114 |
115 | 149 |
150 | ); 151 | }} 152 | /> 153 | ); 154 | }; 155 | 156 | export default VariantsImagesMediaForm; 157 | -------------------------------------------------------------------------------- /src/admin/VariantsImages/VariantsImagesModal.tsx: -------------------------------------------------------------------------------- 1 | import { Product, ProductVariant } from '@medusajs/medusa'; 2 | import { useEffect, useState } from 'react'; 3 | import { useForm } from 'react-hook-form'; 4 | import { 5 | useAdminUpdateProduct, 6 | useAdminUpdateVariant, 7 | useMedusa, 8 | } from 'medusa-react'; 9 | import { Button, FocusModal } from '@medusajs/ui'; 10 | import { nestedForm } from './utils/nestedForm'; 11 | import { prepareImages } from './utils/images'; 12 | import VariantsImagesMediaForm, { 13 | MediaFormType, 14 | } from './VariantsImagesMediaForm'; 15 | 16 | type Notify = { 17 | success: (title: string, message: string) => void; 18 | error: (title: string, message: string) => void; 19 | warn: (title: string, message: string) => void; 20 | info: (title: string, message: string) => void; 21 | }; 22 | 23 | export type FormImage = { 24 | url: string; 25 | name?: string; 26 | size?: number; 27 | nativeFile?: File; 28 | }; 29 | 30 | type Props = { 31 | product: Product; 32 | variant: ProductVariant; 33 | open: boolean; 34 | onClose: () => void; 35 | notify: Notify; 36 | type: 'thumbnail' | 'media'; 37 | }; 38 | 39 | type MediaFormWrapper = { 40 | media: MediaFormType; 41 | }; 42 | 43 | const VariantsImagesModal = ({ 44 | variant, 45 | open, 46 | onClose, 47 | product, 48 | notify, 49 | type, 50 | }: Props) => { 51 | const { client } = useMedusa(); 52 | const [isUpdating, setIsUpdating] = useState(false); 53 | const adminUpdateVariant = useAdminUpdateVariant(product?.id); 54 | const adminUpdateProduct = useAdminUpdateProduct(product?.id); 55 | const form = useForm({ 56 | defaultValues: getDefaultValues(product, variant, type), 57 | }); 58 | 59 | const { 60 | formState: { isDirty }, 61 | handleSubmit, 62 | reset, 63 | } = form; 64 | 65 | useEffect(() => { 66 | reset(getDefaultValues(product, variant, type)); 67 | }, [reset, product, variant, type]); 68 | 69 | const onReset = () => { 70 | reset(getDefaultValues(product, variant, type)); 71 | onClose(); 72 | }; 73 | 74 | const onSubmit = handleSubmit(async (data: any) => { 75 | setIsUpdating(true); 76 | let preppedImages: FormImage[] = []; 77 | 78 | try { 79 | preppedImages = await prepareImages( 80 | data.media.images, 81 | client.admin.uploads 82 | ); 83 | } catch (error) { 84 | let errorMessage = 'Something went wrong while trying to upload images.'; 85 | const response = (error as any).response as Response; 86 | 87 | if (response.status === 500) { 88 | errorMessage = `${errorMessage} You might not have a file service configured. Please contact your administrator.`; 89 | } 90 | 91 | notify.error('Error', errorMessage); 92 | return; 93 | } 94 | const urls = preppedImages.map((image) => image.url); 95 | await adminUpdateProduct.mutate({ images: urls }); 96 | 97 | if (type === 'thumbnail') { 98 | const thumbnail = 99 | data.media.images.find((image) => image.selected)?.url || null; 100 | 101 | await adminUpdateVariant.mutate({ 102 | variant_id: variant.id, 103 | // @ts-ignore 104 | thumbnail, 105 | }); 106 | } else { 107 | const images = data.media.images 108 | .map(({ selected }, i: number) => selected && urls[i]) 109 | .filter(Boolean); 110 | 111 | await adminUpdateVariant.mutate({ 112 | variant_id: variant.id, 113 | // @ts-ignore 114 | images, 115 | }); 116 | } 117 | 118 | onClose(); 119 | setIsUpdating(false); 120 | }); 121 | 122 | return ( 123 | 124 | 125 | 126 | 135 | 136 | 137 |
138 |
139 |

Media

140 |

141 | Add images to your product media. 142 |

143 |
144 | 148 |
149 |
150 |
151 |
152 |
153 |
154 | ); 155 | }; 156 | 157 | const getDefaultValues = ( 158 | product: Product, 159 | variant: ProductVariant, 160 | type: 'thumbnail' | 'media' 161 | ): MediaFormWrapper => { 162 | return { 163 | media: { 164 | images: 165 | product?.images?.map((image) => ({ 166 | url: image.url, 167 | selected: 168 | type === 'thumbnail' 169 | ? variant.thumbnail === image.url 170 | : variant?.images?.some((vImage) => vImage.url === image.url) ?? 171 | false, 172 | })) || [], 173 | }, 174 | }; 175 | }; 176 | 177 | export default VariantsImagesModal; 178 | -------------------------------------------------------------------------------- /src/admin/VariantsImages/components/FileUploadField.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React, { useRef, useState } from 'react'; 3 | 4 | type FileUploadFieldProps = { 5 | onFileChosen: (files: File[]) => void; 6 | filetypes: string[]; 7 | errorMessage?: string; 8 | placeholder?: React.ReactElement | string; 9 | className?: string; 10 | multiple?: boolean; 11 | text?: React.ReactElement | string; 12 | }; 13 | 14 | const defaultText = ( 15 | 16 | Drop your images here, or{' '} 17 | click to browse 18 | 19 | ); 20 | 21 | const FileUploadField: React.FC = ({ 22 | onFileChosen, 23 | filetypes, 24 | errorMessage, 25 | className, 26 | text = defaultText, 27 | placeholder = '', 28 | multiple = false, 29 | }) => { 30 | const inputRef = useRef(null); 31 | const [fileUploadError, setFileUploadError] = useState(false); 32 | 33 | const handleFileUpload = (e: React.ChangeEvent) => { 34 | const fileList = e.target.files; 35 | 36 | if (fileList) { 37 | onFileChosen(Array.from(fileList)); 38 | } 39 | }; 40 | 41 | const handleFileDrop = (e: React.DragEvent) => { 42 | setFileUploadError(false); 43 | 44 | e.preventDefault(); 45 | 46 | const files: File[] = []; 47 | 48 | if (e.dataTransfer.items) { 49 | // Use DataTransferItemList interface to access the file(s) 50 | for (let i = 0; i < e.dataTransfer.items.length; i++) { 51 | // If dropped items aren't files, reject them 52 | if (e.dataTransfer.items[i].kind === 'file') { 53 | const file = e.dataTransfer.items[i].getAsFile(); 54 | if (file && filetypes.indexOf(file.type) > -1) { 55 | files.push(file); 56 | } 57 | } 58 | } 59 | } else { 60 | // Use DataTransfer interface to access the file(s) 61 | for (let i = 0; i < e.dataTransfer.files.length; i++) { 62 | if (filetypes.indexOf(e.dataTransfer.files[i].type) > -1) { 63 | files.push(e.dataTransfer.files[i]); 64 | } 65 | } 66 | } 67 | if (files.length === 1) { 68 | onFileChosen(files); 69 | } else { 70 | setFileUploadError(true); 71 | } 72 | }; 73 | 74 | return ( 75 |
inputRef?.current?.click()} 77 | onDrop={handleFileDrop} 78 | onDragOver={(e) => e.preventDefault()} 79 | className={clsx( 80 | 'inter-base-regular text-grey-50 rounded-rounded border-grey-20 hover:border-violet-60 hover:text-grey-40 flex h-full w-full cursor-pointer select-none flex-col items-center justify-center border-2 border-dashed transition-colors', 81 | className 82 | )} 83 | > 84 |
85 |

{text}

86 | {placeholder} 87 |
88 | {fileUploadError && ( 89 | 90 | {errorMessage || 'Please upload an image file'} 91 | 92 | )} 93 | 101 |
102 | ); 103 | }; 104 | 105 | export default FileUploadField; 106 | -------------------------------------------------------------------------------- /src/admin/VariantsImages/utils/images.ts: -------------------------------------------------------------------------------- 1 | export type FormImage = { 2 | url: string; 3 | name?: string; 4 | size?: number; 5 | nativeFile?: File; 6 | }; 7 | 8 | const splitImages = ( 9 | images: FormImage[] 10 | ): { uploadImages: FormImage[]; existingImages: FormImage[] } => { 11 | const uploadImages: FormImage[] = []; 12 | const existingImages: FormImage[] = []; 13 | 14 | images.forEach((image) => { 15 | if (image.nativeFile) { 16 | uploadImages.push(image); 17 | } else { 18 | existingImages.push(image); 19 | } 20 | }); 21 | 22 | return { uploadImages, existingImages }; 23 | }; 24 | 25 | export const prepareImages = async (images: FormImage[], uploads: any) => { 26 | const { uploadImages, existingImages } = splitImages(images); 27 | 28 | let uploadedImgs: FormImage[] = []; 29 | if (uploadImages.length > 0) { 30 | const files = uploadImages.map((i) => i.nativeFile); 31 | const { uploads: uploaded } = await uploads.create(files); 32 | uploadedImgs = uploaded; 33 | } 34 | 35 | return [...existingImages, ...uploadedImgs]; 36 | }; 37 | -------------------------------------------------------------------------------- /src/admin/VariantsImages/utils/nestedForm.ts: -------------------------------------------------------------------------------- 1 | import { get } from "lodash"; 2 | import { FieldPath, FieldValues, UseFormReturn } from "react-hook-form"; 3 | import { Get } from "type-fest"; 4 | 5 | export type NestedForm = UseFormReturn<{ 6 | __nested__: TValues; 7 | }> & { 8 | path(this: void): `__nested__`; 9 | path>( 10 | this: void, 11 | p?: TPath 12 | ): `__nested__.${TPath}`; 13 | get(this: void, obj: TObj): Get; 14 | get, TObj>( 15 | this: void, 16 | obj: TObj, 17 | p?: TPath 18 | ): Get; 19 | }; 20 | 21 | /** 22 | * Utility function to create a nested form. This is useful when you want to use a reusable form component within a form. 23 | * This is especially useful when you want to use a reusable form component within a form multiple times. For example, an form 24 | * that contains both a billing and a shipping address. 25 | * @example 26 | * const MyForm = () => { 27 | * const form = useForm<{ email: string, shipping_address: AddressPayload, billing_address: AddressPayload }>() 28 | * 29 | * return ( 30 | *
31 | * 32 | * 33 | * 34 | *
35 | * ) 36 | * } 37 | * 38 | * type AddressFormProps = { 39 | * form: NestedForm 40 | * } 41 | * 42 | * const AddressForm = ({ form }: AddressFormProps) => { 43 | * const { register, path } = form 44 | * 45 | * return ( 46 | *
47 | * // path("city") resolves as "shipping_address.city" or "billing_address.city" depending on the second argument passed to nestedForm 48 | * 49 | *
50 | * ) 51 | * } 52 | */ 53 | export function nestedForm( 54 | form: UseFormReturn | NestedForm 55 | ): NestedForm; 56 | // @ts-ignore 57 | export function nestedForm< 58 | TValues extends FieldValues, 59 | TPath extends FieldPath 60 | >( 61 | form: UseFormReturn | NestedForm, 62 | path: TPath 63 | ): NestedForm>; 64 | export function nestedForm( 65 | form: UseFormReturn | NestedForm, 66 | path?: string | number 67 | ): NestedForm { 68 | return { 69 | ...form, 70 | path(field?: string | number) { 71 | const fullPath = path && field ? `${path}.${field}` : path ? path : field; 72 | 73 | if ("path" in form) { 74 | return form.path(path as any); 75 | } 76 | 77 | return (fullPath || "") as any; 78 | }, 79 | get(obj: any, field?: string | number) { 80 | const fullPath = path && field ? `${path}.${field}` : path ? path : field; 81 | 82 | if ("get" in form) { 83 | return form.get(path); 84 | } 85 | 86 | return fullPath ? get(obj, fullPath) : obj; 87 | }, 88 | }; 89 | } 90 | -------------------------------------------------------------------------------- /src/admin/widgets/VariantsImagesWidget.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Button, Container, DropdownMenu, Heading } from '@medusajs/ui'; 3 | import { ProductDetailsWidgetProps, WidgetConfig } from '@medusajs/admin'; 4 | import { ProductVariant } from '@medusajs/medusa'; 5 | import { EllipsisHorizontal, PencilSquare } from '@medusajs/icons'; 6 | import VariantsImagesModal from '../VariantsImages/VariantsImagesModal'; 7 | 8 | const VariantsImagesWidget = ({ 9 | product, 10 | notify, 11 | }: ProductDetailsWidgetProps) => { 12 | const [openedVariant, setOpenedVariant] = useState( 13 | null 14 | ); 15 | const [openedDialogType, setOpenedDialogType] = useState< 16 | 'media' | 'thumbnail' | null 17 | >(null); 18 | 19 | const handleClose = () => { 20 | setOpenedVariant(null); 21 | setOpenedDialogType(null); 22 | }; 23 | 24 | return ( 25 | <> 26 | 27 | 31 |
Variants Images
32 |
33 | {product.variants.map((variant) => ( 34 |
35 |
36 |
{variant.title}
37 | 38 | 39 | 42 | 43 | 44 | { 46 | setOpenedVariant(variant); 47 | setOpenedDialogType('thumbnail'); 48 | }} 49 | className="gap-x-2" 50 | > 51 | 52 | Edit Thumbnail 53 | 54 | { 56 | setOpenedVariant(variant); 57 | setOpenedDialogType('media'); 58 | }} 59 | className="gap-x-2" 60 | > 61 | 62 | Edit Media 63 | 64 | 65 | 66 |
67 |
68 | {variant.thumbnail ? ( 69 | Thumbnail 74 | ) : ( 75 |
76 | No thumbnail 77 |
78 | )} 79 | 80 | {variant.images.length ? ( 81 | variant.images.map((image) => ( 82 | Uploaded image 88 | )) 89 | ) : ( 90 | No images... 91 | )} 92 |
93 |
94 | ))} 95 |
96 | 97 | {openedDialogType && ( 98 | 106 | )} 107 | 108 | ); 109 | }; 110 | 111 | export const config: WidgetConfig = { 112 | zone: 'product.details.after', 113 | }; 114 | 115 | export default VariantsImagesWidget; 116 | -------------------------------------------------------------------------------- /src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { registerOverriddenValidators } from '@medusajs/medusa'; 2 | import {IsArray, IsOptional, IsString} from 'class-validator'; 3 | import { AdminPostProductsProductVariantsVariantReq as MedusaAdminPostProductsProductVariantsVariantReq } from '@medusajs/medusa/dist/api/routes/admin/products/update-variant'; 4 | 5 | class AdminPostProductsProductVariantsVariantReq extends MedusaAdminPostProductsProductVariantsVariantReq { 6 | @IsArray() 7 | @IsOptional() 8 | images?: string[]; 9 | 10 | @IsString() 11 | @IsOptional() 12 | thumbnail?: string; 13 | } 14 | 15 | registerOverriddenValidators(AdminPostProductsProductVariantsVariantReq); -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Image } from '@medusajs/medusa'; 2 | 3 | export declare module '@medusajs/medusa/dist/models/product-variant' { 4 | declare interface ProductVariant { 5 | images: Image[]; 6 | thumbnail?: string; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/loaders/product-variant.ts: -------------------------------------------------------------------------------- 1 | export default async function () { 2 | const adminVariantsImports = (await import( 3 | '@medusajs/medusa/dist/api/routes/admin/variants/index' 4 | )) as any; 5 | 6 | const storeVariantsImports = (await import( 7 | '@medusajs/medusa/dist/api/routes/store/variants/index' 8 | )) as any; 9 | 10 | adminVariantsImports.defaultAdminVariantRelations = [ 11 | ...adminVariantsImports.defaultAdminVariantRelations, 12 | 'images', 13 | ]; 14 | 15 | storeVariantsImports.defaultStoreVariantRelations = [ 16 | ...storeVariantsImports.defaultStoreVariantRelations, 17 | 'images', 18 | ]; 19 | 20 | storeVariantsImports.allowedStoreVariantRelations = [ 21 | ...storeVariantsImports.allowedStoreVariantRelations, 22 | 'images', 23 | ]; 24 | } 25 | -------------------------------------------------------------------------------- /src/loaders/product.ts: -------------------------------------------------------------------------------- 1 | export default async function () { 2 | const adminProductImports = (await import( 3 | '@medusajs/medusa/dist/api/routes/admin/products/index' 4 | )) as any; 5 | 6 | const storeProductImports = (await import( 7 | '@medusajs/medusa/dist/api/routes/store/products/index' 8 | )) as any; 9 | 10 | adminProductImports.defaultAdminProductRelations = [ 11 | ...adminProductImports.defaultAdminProductRelations, 12 | 'variants.images', 13 | ]; 14 | 15 | storeProductImports.defaultStoreProductsRelations = [ 16 | ...storeProductImports.defaultStoreProductsRelations, 17 | 'variants.images', 18 | ] 19 | 20 | storeProductImports.allowedStoreProductsRelations = [ 21 | ...storeProductImports.allowedStoreProductsRelations, 22 | 'variants.images', 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /src/migrations/1688410506235-product_variant_images.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class productVariantImages1688410506235 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.query( 6 | `CREATE TABLE "product_variant_images" ("variant_id" character varying NOT NULL, "image_id" character varying NOT NULL, CONSTRAINT "PK_variant_image" PRIMARY KEY ("variant_id", "image_id"))` 7 | ); 8 | await queryRunner.query( 9 | `CREATE INDEX "IDX_product_variant_images_variant_id" ON "product_variant_images" ("variant_id") ` 10 | ); 11 | await queryRunner.query( 12 | `CREATE INDEX "IDX_product_variant_images_image_id" ON "product_variant_images" ("image_id") ` 13 | ); 14 | 15 | await queryRunner.query( 16 | `ALTER TABLE "product_variant_images" ADD CONSTRAINT "FK_product_variant_images_variant_id" FOREIGN KEY ("variant_id") REFERENCES "product_variant"("id") ON DELETE CASCADE ON UPDATE NO ACTION` 17 | ); 18 | await queryRunner.query( 19 | `ALTER TABLE "product_variant_images" ADD CONSTRAINT "FK_product_variant_images_image_id" FOREIGN KEY ("image_id") REFERENCES "image"("id") ON DELETE CASCADE ON UPDATE NO ACTION` 20 | ); 21 | } 22 | 23 | public async down(queryRunner: QueryRunner): Promise { 24 | await queryRunner.query( 25 | `ALTER TABLE "product_variant_images" DROP CONSTRAINT "FK_product_variant_images_variant_id"` 26 | ); 27 | await queryRunner.query( 28 | `ALTER TABLE "product_variant_images" DROP CONSTRAINT "FK_product_variant_images_image_id"` 29 | ); 30 | await queryRunner.query(`DROP TABLE "product_variant_images"`); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/migrations/1696434674696-product_variant_thumbnail.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from "typeorm" 2 | 3 | export class ProductVariantThumbnail1696434674696 implements MigrationInterface { 4 | public async up(queryRunner: QueryRunner): Promise { 5 | await queryRunner.query( 6 | 'ALTER TABLE "product_variant"' + ' ADD COLUMN "thumbnail" text' 7 | ); 8 | } 9 | 10 | public async down(queryRunner: QueryRunner): Promise { 11 | await queryRunner.query( 12 | 'ALTER TABLE "product_variant" DROP COLUMN "thumbnail"' 13 | ); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/models/product-variant.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, JoinTable, ManyToMany } from 'typeorm'; 2 | import { 3 | ProductVariant as MedusaProductVariant, 4 | Image, 5 | } from '@medusajs/medusa'; 6 | 7 | @Entity() 8 | export class ProductVariant extends MedusaProductVariant { 9 | @ManyToMany(() => Image, { cascade: ['insert'] }) 10 | @JoinTable({ 11 | name: 'product_variant_images', 12 | joinColumn: { 13 | name: 'variant_id', 14 | referencedColumnName: 'id', 15 | }, 16 | inverseJoinColumn: { 17 | name: 'image_id', 18 | referencedColumnName: 'id', 19 | }, 20 | }) 21 | images: Image[]; 22 | 23 | @Column({ type: 'text', nullable: true }) 24 | thumbnail: string | null 25 | } 26 | -------------------------------------------------------------------------------- /src/services/product-variant.ts: -------------------------------------------------------------------------------- 1 | import { Lifetime } from 'awilix'; 2 | import { 3 | ProductVariantService as MedusaProductVariantService, 4 | ProductVariant, 5 | } from '@medusajs/medusa'; 6 | import { Logger } from '@medusajs/types'; 7 | import { UpdateProductVariantInput as MedusaUpdateProductVariantInput } from '@medusajs/medusa/dist/types/product-variant'; 8 | import ImageRepository from '@medusajs/medusa/dist/repositories/image'; 9 | 10 | type UpdateProductVariantInput = { 11 | images: string[]; 12 | } & MedusaUpdateProductVariantInput; 13 | 14 | class ProductVariantService extends MedusaProductVariantService { 15 | static LIFE_TIME = Lifetime.SCOPED; 16 | protected readonly imageRepository_: typeof ImageRepository; 17 | protected readonly logger_: Logger; 18 | 19 | constructor(container) { 20 | super(container); 21 | 22 | this.imageRepository_ = container.imageRepository; 23 | this.logger_ = container.logger; 24 | } 25 | 26 | // @ts-ignore 27 | async update( 28 | variantOrVariantId: string | Partial, 29 | update: UpdateProductVariantInput 30 | ): Promise { 31 | if (update?.images) { 32 | const variant = await this.retrieve(variantOrVariantId as string); 33 | const variantRepo = this.activeManager_.withRepository( 34 | this.productVariantRepository_ 35 | ); 36 | const imageRepo = this.manager_.withRepository(this.imageRepository_); 37 | variant.images = await imageRepo.upsertImages(update.images); 38 | 39 | await variantRepo.save(variant); 40 | } 41 | 42 | delete update.images; 43 | 44 | return super.update(variantOrVariantId, update); 45 | } 46 | } 47 | 48 | export default ProductVariantService; 49 | -------------------------------------------------------------------------------- /src/validators/related-products.ts: -------------------------------------------------------------------------------- 1 | import { AdminPostProductsProductVariantsVariantReq as MedusaAdminPostProductsProductVariantsVariantReq } from '@medusajs/medusa'; 2 | import { IsOptional, IsArray, IsString } from 'class-validator'; 3 | 4 | export class AdminPostProductsProductVariantsVariantReq extends MedusaAdminPostProductsProductVariantsVariantReq { 5 | @IsArray() 6 | @IsOptional() 7 | images?: string[]; 8 | 9 | @IsString() 10 | @IsOptional() 11 | thumbnail?: string; 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.admin.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext" 5 | }, 6 | "include": ["src/admin"], 7 | "exclude": ["**/*.spec.js"] 8 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "module": "commonjs", 5 | "allowJs": true, 6 | "checkJs": false, 7 | "jsx": "react-jsx", 8 | "declaration": true, 9 | "outDir": "./dist", 10 | "rootDir": "./src", 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "noEmit": false, 14 | "strict": false, 15 | "moduleResolution": "node", 16 | "esModuleInterop": true, 17 | "resolveJsonModule": true, 18 | "skipLibCheck": true, 19 | "forceConsistentCasingInFileNames": true 20 | }, 21 | "include": ["src/"], 22 | "exclude": [ 23 | "dist", 24 | "build", 25 | ".cache", 26 | "tests", 27 | "**/*.spec.js", 28 | "**/*.spec.ts", 29 | "node_modules", 30 | ".eslintrc.js" 31 | ] 32 | } -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | /* Emit a single file with source maps instead of having a separate file. */ 5 | "inlineSourceMap": true 6 | }, 7 | "exclude": ["src/admin", "**/*.spec.js"] 8 | } -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules"] 5 | } --------------------------------------------------------------------------------