diff --git a/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx b/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx
index 388f93f3f0..58b17b186f 100644
--- a/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx
+++ b/packages/admin-next/dashboard/src/components/common/action-menu/action-menu.tsx
@@ -4,7 +4,7 @@ import { EllipsisHorizontal } from "@medusajs/icons"
import { ReactNode } from "react"
import { Link } from "react-router-dom"
-type Action = {
+export type Action = {
icon: ReactNode
label: string
disabled?: boolean
@@ -19,7 +19,7 @@ type Action = {
}
)
-type ActionGroup = {
+export type ActionGroup = {
actions: Action[]
}
diff --git a/packages/admin-next/dashboard/src/components/common/file-preview/file-preview.tsx b/packages/admin-next/dashboard/src/components/common/file-preview/file-preview.tsx
new file mode 100644
index 0000000000..e7a2fa8361
--- /dev/null
+++ b/packages/admin-next/dashboard/src/components/common/file-preview/file-preview.tsx
@@ -0,0 +1,130 @@
+import { ArrowDownTray, Spinner } from "@medusajs/icons"
+import { IconButton, Text } from "@medusajs/ui"
+import { ActionGroup, ActionMenu } from "../action-menu"
+
+export const FilePreview = ({
+ filename,
+ url,
+ loading,
+ activity,
+ actions,
+ hideThumbnail,
+}: {
+ filename: string
+ url?: string
+ loading?: boolean
+ activity?: string
+ actions?: ActionGroup[]
+ hideThumbnail?: boolean
+}) => {
+ return (
+
diff --git a/packages/admin-next/dashboard/src/hooks/api/products.tsx b/packages/admin-next/dashboard/src/hooks/api/products.tsx
index f12ecb64d3..80fee0531e 100644
--- a/packages/admin-next/dashboard/src/hooks/api/products.tsx
+++ b/packages/admin-next/dashboard/src/hooks/api/products.tsx
@@ -343,3 +343,31 @@ export const useExportProducts = (
...options,
})
}
+
+export const useImportProducts = (
+ options?: UseMutationOptions<
+ HttpTypes.AdminImportProductResponse,
+ FetchError,
+ HttpTypes.AdminImportProductRequest
+ >
+) => {
+ return useMutation({
+ mutationFn: (payload) => sdk.admin.product.import(payload),
+ onSuccess: (data, variables, context) => {
+ options?.onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
+
+export const useConfirmImportProducts = (
+ options?: UseMutationOptions<{}, FetchError, string>
+) => {
+ return useMutation({
+ mutationFn: (payload) => sdk.admin.product.confirmImport(payload),
+ onSuccess: (data, variables, context) => {
+ options?.onSuccess?.(data, variables, context)
+ },
+ ...options,
+ })
+}
diff --git a/packages/admin-next/dashboard/src/i18n/translations/en.json b/packages/admin-next/dashboard/src/i18n/translations/en.json
index 1f670696d0..7150e68fce 100644
--- a/packages/admin-next/dashboard/src/i18n/translations/en.json
+++ b/packages/admin-next/dashboard/src/i18n/translations/en.json
@@ -350,7 +350,22 @@
},
"import": {
"header": "Import Product List",
- "description": "Import products by providing a CSV file in a pre-defined format"
+ "description": "Import products by providing a CSV file in a pre-defined format",
+ "template": {
+ "title": "Unsure about how to arrange your list?",
+ "description": "Download the template below to ensure you are following the correct format."
+ },
+ "upload": {
+ "title": "Upload a CSV file",
+ "description": "Through imports you can add or update products. To update existing products you must use the existing handle and ID, to update existing variants you must use the existing ID. You will be asked for confirmation before we import products.",
+ "preprocessing": "Preprocessing...",
+ "productsToCreate": "Products will be created",
+ "productsToUpdate": "Products will be updated"
+ },
+ "success": {
+ "title": "We are processing your import",
+ "description": "Importing data may take a while. We will notify you when we are done."
+ }
},
"deleteWarning": "You are about to delete the product {{title}}. This action cannot be undone.",
"variants": "Variants",
diff --git a/packages/admin-next/dashboard/src/routes/products/product-import/components/import-summary.tsx b/packages/admin-next/dashboard/src/routes/products/product-import/components/import-summary.tsx
new file mode 100644
index 0000000000..5f15b5942a
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-import/components/import-summary.tsx
@@ -0,0 +1,51 @@
+import { HttpTypes } from "@medusajs/types"
+import { Divider } from "../../../../components/common/divider"
+import { Text } from "@medusajs/ui"
+import { useTranslation } from "react-i18next"
+
+export const ImportSummary = ({
+ summary,
+}: {
+ summary: HttpTypes.AdminImportProductResponse["summary"]
+}) => {
+ const { t } = useTranslation()
+
+ return (
+
+ )
+}
+
+const Stat = ({
+ title,
+ description,
+}: {
+ title: string
+ description: string
+}) => {
+ return (
+
+
+ {title}
+
+
+
+ {description}
+
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-import/components/upload-import.tsx b/packages/admin-next/dashboard/src/routes/products/product-import/components/upload-import.tsx
new file mode 100644
index 0000000000..e722aaa4f4
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-import/components/upload-import.tsx
@@ -0,0 +1,60 @@
+import { useState } from "react"
+import { FileType, FileUpload } from "../../../../components/common/file-upload"
+import { Hint } from "@medusajs/ui"
+import { useTranslation } from "react-i18next"
+
+const SUPPORTED_FORMATS = ["text/csv"]
+const SUPPORTED_FORMATS_FILE_EXTENSIONS = [".csv"]
+
+export const UploadImport = ({
+ onUploaded,
+}: {
+ onUploaded: (file: File) => void
+}) => {
+ const { t } = useTranslation()
+ const [error, setError] = useState
()
+
+ const hasInvalidFiles = (fileList: FileType[]) => {
+ const invalidFile = fileList.find(
+ (f) => !SUPPORTED_FORMATS.includes(f.file.type)
+ )
+
+ if (invalidFile) {
+ setError(
+ t("products.media.invalidFileType", {
+ name: invalidFile.file.name,
+ types: SUPPORTED_FORMATS_FILE_EXTENSIONS.join(", "),
+ })
+ )
+
+ return true
+ }
+
+ return false
+ }
+
+ return (
+
+
{
+ setError(undefined)
+ if (hasInvalidFiles(files)) {
+ return
+ }
+ onUploaded(files[0].file)
+ }}
+ />
+
+ {error && (
+
+ {error}
+
+ )}
+
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-import/helpers/import-template.ts b/packages/admin-next/dashboard/src/routes/products/product-import/helpers/import-template.ts
new file mode 100644
index 0000000000..ba29b691d2
--- /dev/null
+++ b/packages/admin-next/dashboard/src/routes/products/product-import/helpers/import-template.ts
@@ -0,0 +1,13 @@
+const ProductImportCSV =
+ "data:text/csv;charset=utf-8," +
+ `Product Id;Product Handle;Product Title;Product Subtitle;Product Description;Product Status;Product Thumbnail;Product Weight;Product Length;Product Width;Product Height;Product HS Code;Product Origin Country;Product MID Code;Product Material;Product Collection Title;Product Collection Handle;Product Type;Product Tags;Product Discountable;Product External Id;Product Profile Name;Product Profile Type;Variant Id;Variant Title;Variant SKU;Variant Barcode;Variant Inventory Quantity;Variant Allow Backorder;Variant Manage Inventory;Variant Weight;Variant Length;Variant Width;Variant Height;Variant HS Code;Variant Origin Country;Variant MID Code;Variant Material;Price EUR;Price USD;Option 1 Name;Option 1 Value;Image 1 Url;Image 2 Url
+;coffee-mug-v2;Medusa Coffee Mug;;Every programmer's best friend.;published;https://medusa-public-images.s3.eu-west-1.amazonaws.com/coffee-mug.png;400;;;;;;;;;;;;true;;;;;One Size;;;100;false;true;;;;;;;;;1000;1200;Size;One Size;https://medusa-public-images.s3.eu-west-1.amazonaws.com/coffee-mug.png;
+;sweatpants-v2;Medusa Sweatpants;;Reimagine the feeling of classic sweatpants. With our cotton sweatpants, everyday essentials no longer have to be ordinary.;published;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;400;;;;;;;;;;;;true;;;;;S;;;100;false;true;;;;;;;;;2950;3350;Size;S;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-back.png
+;sweatpants-v2;Medusa Sweatpants;;Reimagine the feeling of classic sweatpants. With our cotton sweatpants, everyday essentials no longer have to be ordinary.;published;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;400;;;;;;;;;;;;true;;;;;M;;;100;false;true;;;;;;;;;2950;3350;Size;M;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-back.png
+;sweatpants-v2;Medusa Sweatpants;;Reimagine the feeling of classic sweatpants. With our cotton sweatpants, everyday essentials no longer have to be ordinary.;published;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;400;;;;;;;;;;;;true;;;;;L;;;100;false;true;;;;;;;;;2950;3350;Size;L;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-back.png
+;sweatpants-v2;Medusa Sweatpants;;Reimagine the feeling of classic sweatpants. With our cotton sweatpants, everyday essentials no longer have to be ordinary.;published;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;400;;;;;;;;;;;;true;;;;;XL;;;100;false;true;;;;;;;;;2950;3350;Size;XL;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png;https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-back.png
+`
+
+export const getProductImportCsvTemplate = () => {
+ return encodeURI(ProductImportCSV)
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-import/product-import.tsx b/packages/admin-next/dashboard/src/routes/products/product-import/product-import.tsx
index d8ac5bf515..c5399231c1 100644
--- a/packages/admin-next/dashboard/src/routes/products/product-import/product-import.tsx
+++ b/packages/admin-next/dashboard/src/routes/products/product-import/product-import.tsx
@@ -1,6 +1,13 @@
-import { Heading } from "@medusajs/ui"
-import { RouteDrawer } from "../../../components/modals"
+import { Button, Heading, Text, toast } from "@medusajs/ui"
+import { RouteDrawer, useRouteModal } from "../../../components/modals"
import { useTranslation } from "react-i18next"
+import { useMemo, useState } from "react"
+import { useConfirmImportProducts, useImportProducts } from "../../../hooks/api"
+import { UploadImport } from "./components/upload-import"
+import { ImportSummary } from "./components/import-summary"
+import { Trash } from "@medusajs/icons"
+import { FilePreview } from "../../../components/common/file-preview"
+import { getProductImportCsvTemplate } from "./helpers/import-template"
export const ProductImport = () => {
const { t } = useTranslation()
@@ -15,7 +22,122 @@ export const ProductImport = () => {
{t("products.import.description")}
-
+
)
}
+
+const ProductImportContent = () => {
+ const { t } = useTranslation()
+ const [filename, setFilename] = useState()
+
+ const { mutateAsync: importProducts, isPending, data } = useImportProducts()
+ const { mutateAsync: confirm } = useConfirmImportProducts()
+ const { handleSuccess } = useRouteModal()
+
+ const productImportTemplateContent = useMemo(() => {
+ return getProductImportCsvTemplate()
+ }, [])
+
+ const handleUploaded = async (file: File) => {
+ setFilename(file.name)
+ await importProducts(
+ { file },
+ {
+ onError: (err) => {
+ toast.error(err.message)
+ setFilename(undefined)
+ },
+ }
+ )
+ }
+
+ const handleConfirm = async () => {
+ if (!data?.transaction_id) {
+ return
+ }
+
+ await confirm(data.transaction_id, {
+ onSuccess: () => {
+ toast.info(t("products.import.success.title"), {
+ description: t("products.import.success.description"),
+ })
+ handleSuccess()
+ },
+ onError: (err) => {
+ toast.error(err.message)
+ },
+ })
+ }
+
+ const uploadedFileActions = [
+ {
+ actions: [
+ {
+ label: t("actions.delete"),
+ icon: ,
+ onClick: () => setFilename(undefined),
+ },
+ ],
+ },
+ ]
+
+ return (
+ <>
+
+ {t("products.import.upload.title")}
+
+ {t("products.import.upload.description")}
+
+
+
+ {filename ? (
+
+ ) : (
+
+ )}
+
+
+ {data?.summary && !!filename && (
+
+
+
+ )}
+
+
+ {t("products.import.template.title")}
+
+
+ {t("products.import.template.description")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/packages/admin-next/dashboard/src/routes/products/product-list/components/product-list-table/product-list-table.tsx b/packages/admin-next/dashboard/src/routes/products/product-list/components/product-list-table/product-list-table.tsx
index 7d1279cd75..d725a89315 100644
--- a/packages/admin-next/dashboard/src/routes/products/product-list/components/product-list-table/product-list-table.tsx
+++ b/packages/admin-next/dashboard/src/routes/products/product-list/components/product-list-table/product-list-table.tsx
@@ -64,9 +64,9 @@ export const ProductListTable = () => {
- {/*
{t("actions.create")}
diff --git a/packages/core/js-sdk/src/admin/product.ts b/packages/core/js-sdk/src/admin/product.ts
index dc321670dc..4a57377f37 100644
--- a/packages/core/js-sdk/src/admin/product.ts
+++ b/packages/core/js-sdk/src/admin/product.ts
@@ -1,9 +1,4 @@
-import {
- AdminBatchProductVariantInventoryItemRequest,
- AdminBatchProductVariantInventoryItemResponse,
- HttpTypes,
- SelectParams,
-} from "@medusajs/types"
+import { HttpTypes, SelectParams } from "@medusajs/types"
import { Client } from "../client"
import { ClientHeaders } from "../types"
@@ -13,6 +8,45 @@ export class Product {
this.client = client
}
+ async import(
+ body: HttpTypes.AdminImportProductRequest,
+ query?: {},
+ headers?: ClientHeaders
+ ) {
+ const form = new FormData()
+ form.append("file", body.file)
+
+ return await this.client.fetch(
+ `/admin/products/import`,
+ {
+ method: "POST",
+ headers: {
+ ...headers,
+ // Let the browser determine the content type.
+ "content-type": null,
+ },
+ body: form,
+ query,
+ }
+ )
+ }
+
+ async confirmImport(
+ transactionId: string,
+ query?: {},
+ headers?: ClientHeaders
+ ) {
+ return await this.client.fetch<{}>(
+ `/admin/products/import/${transactionId}/confirm`,
+ {
+ method: "POST",
+ headers,
+ body: {},
+ query,
+ }
+ )
+ }
+
async export(
body: HttpTypes.AdminExportProductRequest,
query?: HttpTypes.AdminProductListParams,
diff --git a/packages/core/types/src/http/product/admin/payloads.ts b/packages/core/types/src/http/product/admin/payloads.ts
index 9ae597950b..7958e8b823 100644
--- a/packages/core/types/src/http/product/admin/payloads.ts
+++ b/packages/core/types/src/http/product/admin/payloads.ts
@@ -2,7 +2,9 @@ import { BatchMethodRequest } from "../../../common"
import { ProductStatus } from "../common"
export interface AdminExportProductRequest {}
-export interface AdminImportProductRequest {}
+export interface AdminImportProductRequest {
+ file: File
+}
export interface AdminBatchProductRequest
extends BatchMethodRequest {}