├── src ├── index.ts ├── admin │ ├── components │ │ ├── TranslationSyncStatus │ │ │ ├── types.ts │ │ │ ├── index.ts │ │ │ └── TranslationSyncStatus.tsx │ │ └── TranslationManagement │ │ │ ├── index.ts │ │ │ ├── types.ts │ │ │ └── TranslationManagement.tsx │ ├── utils │ │ └── formatKeyName.ts │ └── widgets │ │ └── TranslationWidget │ │ └── TranslationWidget.tsx ├── api │ └── admin │ │ ├── multilingual-options │ │ └── route.ts │ │ └── product-translation │ │ └── [product_id] │ │ └── route.ts ├── subscribers │ ├── product-deletion-handler.ts │ └── product-creation-handler.ts ├── strategies │ └── translation-sync.ts └── services │ └── translation-management.ts ├── .vscode └── settings.json ├── .yarnrc.yml ├── tsconfig.spec.json ├── tsconfig.admin.json ├── tsconfig.server.json ├── .babelrc.js ├── .gitignore ├── .npmignore ├── .github └── dependabot.yml ├── tsconfig.json ├── LICENSE ├── index.js ├── medusa-config.js ├── package.json ├── data ├── seed-onboarding.json └── seed.json └── README.md /src/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules -------------------------------------------------------------------------------- /src/admin/components/TranslationSyncStatus/types.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["dist", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /src/admin/components/TranslationManagement/index.ts: -------------------------------------------------------------------------------- 1 | import TranslationManagement from './TranslationManagement'; 2 | 3 | export default TranslationManagement; 4 | -------------------------------------------------------------------------------- /src/admin/components/TranslationSyncStatus/index.ts: -------------------------------------------------------------------------------- 1 | import TranslationSyncStatus from './TranslationSyncStatus'; 2 | 3 | export default TranslationSyncStatus; 4 | -------------------------------------------------------------------------------- /tsconfig.admin.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext" 5 | }, 6 | "include": ["src/admin"], 7 | "exclude": ["**/*.spec.js"] 8 | } 9 | -------------------------------------------------------------------------------- /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 | } 9 | -------------------------------------------------------------------------------- /src/admin/utils/formatKeyName.ts: -------------------------------------------------------------------------------- 1 | const formatKeyName = (keyName) => { 2 | const parts = keyName.split('.'); 3 | if (parts.length > 1) { 4 | return parts[1].charAt(0).toUpperCase() + parts[1].slice(1); 5 | } 6 | return keyName; 7 | }; 8 | 9 | export default formatKeyName; 10 | -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | let ignore = [`**/dist`] 2 | 3 | // Jest needs to compile this code, but generally we don't want this copied 4 | // to output folders 5 | if (process.env.NODE_ENV !== `test`) { 6 | ignore.push(`**/__tests__`) 7 | } 8 | 9 | module.exports = { 10 | presets: [["babel-preset-medusa-package"], ["@babel/preset-typescript"]], 11 | ignore, 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | .env 3 | .DS_Store 4 | /uploads 5 | /node_modules 6 | yarn-error.log 7 | 8 | .idea 9 | 10 | coverage 11 | 12 | !src/** 13 | 14 | ./tsconfig.tsbuildinfo 15 | package-lock.json 16 | yarn.lock 17 | medusa-db.sql 18 | build 19 | .cache 20 | 21 | .yarn/* 22 | !.yarn/patches 23 | !.yarn/plugins 24 | !.yarn/releases 25 | !.yarn/sdks 26 | !.yarn/versions 27 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /lib 2 | node_modules 3 | .DS_store 4 | .env* 5 | /*.js 6 | !index.js 7 | yarn.lock 8 | src 9 | .gitignore 10 | .eslintrc 11 | .babelrc 12 | .prettierrc 13 | build 14 | .cache 15 | .yarn 16 | uploads 17 | 18 | # These are files that are included in a 19 | # Medusa project and can be removed from a 20 | # plugin project 21 | medusa-config.js 22 | Dockerfile 23 | medusa-db.sql 24 | develop.sh 25 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | allow: 8 | - dependency-type: production 9 | groups: 10 | medusa: 11 | patterns: 12 | - "@medusajs*" 13 | - "medusa*" 14 | update-types: 15 | - "minor" 16 | - "patch" 17 | ignore: 18 | - dependency-name: "@medusajs*" 19 | update-types: ["version-update:semver-major"] 20 | - dependency-name: "medusa*" 21 | update-types: ["version-update:semver-major"] 22 | -------------------------------------------------------------------------------- /src/api/admin/multilingual-options/route.ts: -------------------------------------------------------------------------------- 1 | import type { MedusaRequest, MedusaResponse } from "@medusajs/medusa"; 2 | import TranslationManagementService from "../../../services/translation-management"; 3 | 4 | export const GET = async (req: MedusaRequest, res: MedusaResponse) => { 5 | const translationManagementService: TranslationManagementService = 6 | req.scope.resolve("translationManagementService"); 7 | 8 | const defaultLanguage = translationManagementService.getDefaultLanguage(); 9 | const availableLanguages = 10 | translationManagementService.getAvailableLanguages(); 11 | 12 | res.json({ 13 | defaultLanguage, 14 | availableLanguages, 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /src/admin/components/TranslationSyncStatus/TranslationSyncStatus.tsx: -------------------------------------------------------------------------------- 1 | import { useAdminBatchJob } from "medusa-react"; 2 | import { useState, useEffect } from "react"; 3 | 4 | const TranslationSyncStatus = ({ batchJobId, handleSyncStatus }) => { 5 | const { batch_job, isLoading } = useAdminBatchJob(batchJobId); 6 | const [syncStatus, setSyncStatus] = useState("Synchronization in progress..."); 7 | 8 | useEffect(() => { 9 | if (batch_job && batch_job.status === "completed") { 10 | handleSyncStatus(false); 11 | setSyncStatus("Synchronization completed."); 12 | } 13 | }, [batch_job, isLoading]); 14 | 15 | return <>{syncStatus}; 16 | }; 17 | 18 | export default TranslationSyncStatus; 19 | -------------------------------------------------------------------------------- /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 | } 33 | -------------------------------------------------------------------------------- /src/admin/components/TranslationManagement/types.ts: -------------------------------------------------------------------------------- 1 | import type { ProductDetailsWidgetProps } from "@medusajs/admin"; 2 | 3 | export interface Language { 4 | label: string; 5 | tag: string; 6 | }; 7 | 8 | export interface Props extends ProductDetailsWidgetProps { 9 | availableLanguages: Language[]; 10 | defaultLanguage: string; 11 | handleLanguageChange: (lang: string) => void; 12 | refreshObserver: () => void; 13 | refreshKey: boolean; 14 | }; 15 | 16 | export interface RequestQuery { 17 | productId: string; 18 | }; 19 | 20 | export interface ResponseData { 21 | keyNames: string[]; 22 | }; 23 | 24 | export interface TranslationRequest { 25 | product: Pick['product']; 26 | } 27 | 28 | export interface TranslationResponse { 29 | message: string; 30 | data: any; 31 | } 32 | -------------------------------------------------------------------------------- /src/subscribers/product-deletion-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ProductService, 3 | type SubscriberConfig, 4 | type SubscriberArgs, 5 | } from "@medusajs/medusa"; 6 | import TranslationManagementService from "../services/translation-management"; 7 | 8 | interface ProductDeletionEventData { 9 | id: string; 10 | } 11 | 12 | export default async function productDeletionHandler({ 13 | data, 14 | container, 15 | }: SubscriberArgs) { 16 | const translationService: TranslationManagementService = container.resolve( 17 | "translationManagementService" 18 | ); 19 | const { id } = data; 20 | 21 | await translationService.deleteProductTranslations(id); 22 | } 23 | 24 | export const config: SubscriberConfig = { 25 | event: ProductService.Events.DELETED, 26 | context: { 27 | subscriberId: "product-deletion-handler", 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /src/subscribers/product-creation-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ProductService, 3 | type SubscriberConfig, 4 | type SubscriberArgs, 5 | } from "@medusajs/medusa"; 6 | import TranslationManagementService from "../services/translation-management"; 7 | 8 | interface ProductCreationEventData { 9 | id: string; 10 | } 11 | 12 | export default async function productCreationHandler({ 13 | data, 14 | container, 15 | }: SubscriberArgs) { 16 | const productService: ProductService = container.resolve("productService"); 17 | const translationService: TranslationManagementService = container.resolve( 18 | "translationManagementService" 19 | ); 20 | 21 | const { id } = data; 22 | 23 | const product = await productService.retrieve(id); 24 | 25 | await translationService.createProductTranslations(product.id, product); 26 | } 27 | 28 | export const config: SubscriberConfig = { 29 | event: ProductService.Events.CREATED, 30 | context: { 31 | subscriberId: "product-creation-handler", 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Rigby 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const express = require("express") 2 | const { GracefulShutdownServer } = require("medusa-core-utils") 3 | 4 | const loaders = require("@medusajs/medusa/dist/loaders/index").default 5 | 6 | ;(async() => { 7 | async function start() { 8 | const app = express() 9 | const directory = process.cwd() 10 | 11 | try { 12 | const { container } = await loaders({ 13 | directory, 14 | expressApp: app 15 | }) 16 | const configModule = container.resolve("configModule") 17 | const port = process.env.PORT ?? configModule.projectConfig.port ?? 9000 18 | 19 | const server = GracefulShutdownServer.create( 20 | app.listen(port, (err) => { 21 | if (err) { 22 | return 23 | } 24 | console.log(`Server is ready on port: ${port}`) 25 | }) 26 | ) 27 | 28 | // Handle graceful shutdown 29 | const gracefulShutDown = () => { 30 | server 31 | .shutdown() 32 | .then(() => { 33 | console.info("Gracefully stopping the server.") 34 | process.exit(0) 35 | }) 36 | .catch((e) => { 37 | console.error("Error received when shutting down the server.", e) 38 | process.exit(1) 39 | }) 40 | } 41 | process.on("SIGTERM", gracefulShutDown) 42 | process.on("SIGINT", gracefulShutDown) 43 | } catch (err) { 44 | console.error("Error starting server", err) 45 | process.exit(1) 46 | } 47 | } 48 | 49 | await start() 50 | })() 51 | -------------------------------------------------------------------------------- /medusa-config.js: -------------------------------------------------------------------------------- 1 | const dotenv = require("dotenv"); 2 | 3 | let ENV_FILE_NAME = ""; 4 | switch (process.env.NODE_ENV) { 5 | case "production": 6 | ENV_FILE_NAME = ".env.production"; 7 | break; 8 | case "staging": 9 | ENV_FILE_NAME = ".env.staging"; 10 | break; 11 | case "test": 12 | ENV_FILE_NAME = ".env.test"; 13 | break; 14 | case "development": 15 | default: 16 | ENV_FILE_NAME = ".env"; 17 | break; 18 | } 19 | 20 | try { 21 | dotenv.config({ path: process.cwd() + "/" + ENV_FILE_NAME }); 22 | } catch (e) {} 23 | 24 | // CORS when consuming Medusa from admin 25 | const ADMIN_CORS = 26 | process.env.ADMIN_CORS || "http://localhost:7000,http://localhost:7001"; 27 | 28 | // CORS to avoid issues when consuming Medusa from a client 29 | const STORE_CORS = process.env.STORE_CORS || "http://localhost:8000"; 30 | 31 | const DATABASE_URL = 32 | process.env.DATABASE_URL || "postgres://localhost/medusa-starter-default"; 33 | 34 | const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379"; 35 | 36 | const plugins = []; 37 | 38 | const modules = {}; 39 | 40 | /** @type {import('@medusajs/medusa').ConfigModule["projectConfig"]} */ 41 | const projectConfig = { 42 | jwtSecret: process.env.JWT_SECRET, 43 | cookieSecret: process.env.COOKIE_SECRET, 44 | store_cors: STORE_CORS, 45 | database_url: DATABASE_URL, 46 | admin_cors: ADMIN_CORS, 47 | // Uncomment the following lines to enable REDIS 48 | // redis_url: REDIS_URL 49 | }; 50 | 51 | /** @type {import('@medusajs/medusa').ConfigModule} */ 52 | module.exports = { 53 | projectConfig, 54 | plugins, 55 | modules, 56 | }; 57 | -------------------------------------------------------------------------------- /src/api/admin/product-translation/[product_id]/route.ts: -------------------------------------------------------------------------------- 1 | import type { MedusaRequest, MedusaResponse } from "@medusajs/medusa"; 2 | import TranslationManagementService from "../../../../services/translation-management"; 3 | import type { ProductDetailsWidgetProps } from "@medusajs/admin"; 4 | 5 | type TranslationRequestBody = { 6 | product: Pick["product"]; 7 | }; 8 | 9 | export const GET = async (req: MedusaRequest, res: MedusaResponse) => { 10 | const translationManagementService: TranslationManagementService = 11 | req.scope.resolve("translationManagementService"); 12 | const { product_id } = req.params; 13 | 14 | const keyNames = await translationManagementService.getProductTranslationKeys( 15 | product_id 16 | ); 17 | 18 | res.json({ keyNames }); 19 | }; 20 | 21 | export const POST = async (req: MedusaRequest, res: MedusaResponse) => { 22 | const translationManagementService: TranslationManagementService = 23 | req.scope.resolve("translationManagementService"); 24 | const { product_id } = req.params; 25 | 26 | const { product } = req.body as TranslationRequestBody; 27 | 28 | const data = await translationManagementService.createProductTranslations( 29 | product_id, 30 | product 31 | ); 32 | 33 | res.status(201).json(data); 34 | }; 35 | 36 | export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => { 37 | const translationManagementService: TranslationManagementService = 38 | req.scope.resolve("translationManagementService"); 39 | const { product_id } = req.params; 40 | 41 | const result = await translationManagementService.deleteProductTranslations( 42 | product_id 43 | ); 44 | 45 | res.status(200).json(result); 46 | }; 47 | -------------------------------------------------------------------------------- /src/strategies/translation-sync.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AbstractBatchJobStrategy, 3 | BatchJobService, 4 | ProductService, 5 | } from "@medusajs/medusa"; 6 | import { EntityManager } from "typeorm"; 7 | import TranslationManagementService from "../services/translation-management"; 8 | 9 | class TranslationSyncStrategy extends AbstractBatchJobStrategy { 10 | static identifier = "translation-sync-strategy"; 11 | static batchType = "translation-sync"; 12 | 13 | protected batchJobService_: BatchJobService; 14 | protected productService_: ProductService; 15 | protected translationService_: TranslationManagementService; 16 | protected manager_: EntityManager; 17 | protected transactionManager_: EntityManager; 18 | 19 | constructor({ 20 | productService, 21 | translationManagementService, 22 | manager, 23 | batchJobService, 24 | }) { 25 | super(arguments[0]); 26 | this.productService_ = productService; 27 | this.batchJobService_ = batchJobService; 28 | this.translationService_ = translationManagementService; 29 | this.manager_ = manager; 30 | } 31 | 32 | async processJob(batchJobId: string): Promise { 33 | return await this.atomicPhase_(async (transactionManager) => { 34 | const productList = await this.productService_ 35 | .withTransaction(transactionManager) 36 | .list({}); 37 | 38 | for (const product of productList) { 39 | await this.translationService_.createProductTranslations( 40 | product.id, 41 | product 42 | ); 43 | } 44 | 45 | await this.batchJobService_ 46 | .withTransaction(transactionManager) 47 | .update(batchJobId, { 48 | result: { 49 | advancement_count: productList.length, 50 | }, 51 | }); 52 | }); 53 | } 54 | 55 | async buildTemplate(): Promise { 56 | return ""; 57 | } 58 | } 59 | 60 | export default TranslationSyncStrategy; 61 | -------------------------------------------------------------------------------- /src/admin/widgets/TranslationWidget/TranslationWidget.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import type { WidgetConfig, ProductDetailsWidgetProps } from "@medusajs/admin"; 3 | import { useAdminCustomQuery } from "medusa-react"; 4 | import { Tolgee, TolgeeProvider, FormatSimple } from "@tolgee/react"; 5 | import { InContextTools } from "@tolgee/web/tools"; 6 | 7 | import TranslationManagement from "../../components/TranslationManagement"; 8 | 9 | interface Language { 10 | label: string; 11 | tag: string; 12 | } 13 | 14 | export interface ResponseData { 15 | defaultLanguage: string; 16 | availableLanguages: Language[]; 17 | } 18 | 19 | const TranslationWidget = ({ product, notify }: ProductDetailsWidgetProps) => { 20 | const [tolgee, setTolgee] = useState(null); 21 | const [refreshKey, setRefreshKey] = useState(false); 22 | 23 | const { data } = useAdminCustomQuery( 24 | "/admin/multilingual-options", 25 | ["defaultLanguage", "availableLanguages"] 26 | ); 27 | 28 | useEffect(() => { 29 | if (data && !tolgee) { 30 | const languages = data.availableLanguages.map((lang) => lang.tag); 31 | 32 | const tolgeeInstance = Tolgee() 33 | .use(FormatSimple()) 34 | .init({ 35 | language: data.defaultLanguage, 36 | apiUrl: process.env.MEDUSA_ADMIN_TOLGEE_API_URL, 37 | apiKey: process.env.MEDUSA_ADMIN_TOLGEE_API_KEY, 38 | availableLanguages: languages, 39 | observerOptions: { 40 | highlightColor: "rgba(0,0,0,0.7)", 41 | }, 42 | }); 43 | 44 | tolgeeInstance.addPlugin(InContextTools()); 45 | setTolgee(tolgeeInstance); 46 | } 47 | }, [data]); 48 | 49 | const handleLanguageChange = async (lang: string) => { 50 | if (tolgee) { 51 | await tolgee.changeLanguage(lang); 52 | } 53 | }; 54 | 55 | const refreshObserver = () => { 56 | setRefreshKey(!refreshKey); 57 | tolgee.addPlugin(InContextTools()); 58 | }; 59 | 60 | return ( 61 | <> 62 | {tolgee ? ( 63 | 64 | 73 | 74 | ) : ( 75 |
Loading...
76 | )} 77 | 78 | ); 79 | }; 80 | 81 | export const config: WidgetConfig = { 82 | zone: "product.details.after", 83 | }; 84 | 85 | export default TranslationWidget; 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "medusa-multilingual-tolgee", 3 | "version": "1.0.0", 4 | "description": "Translate your product information with ease.", 5 | "author": "Rigby (https://rigbyjs.com/en)", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/rigby-sh/medusa-multilingual-tolgee" 10 | }, 11 | "keywords": [ 12 | "medusa-plugin", 13 | "medusa-plugin-admin", 14 | "medusa-plugin-storefront", 15 | "medusa-plugin-other", 16 | "medusa-multilingual", 17 | "multilingual", 18 | "translation", 19 | "tolgee", 20 | "ecommerce", 21 | "headless", 22 | "medusa" 23 | ], 24 | "scripts": { 25 | "clean": "cross-env ./node_modules/.bin/rimraf dist", 26 | "watch": "cross-env tsc --watch", 27 | "test": "cross-env jest", 28 | "seed": "cross-env medusa seed -f ./data/seed.json", 29 | "start": "cross-env npm run build && medusa start", 30 | "start:custom": "cross-env npm run build && node --preserve-symlinks index.js", 31 | "dev": "cross-env npm run build:server && medusa develop", 32 | "build": "cross-env npm run clean && npm run build:server && npm run build:admin", 33 | "build:admin": "cross-env medusa-admin build", 34 | "build:server": "cross-env npm run clean && tsc -p tsconfig.json", 35 | "prepare": "cross-env NODE_ENV=production npm run build:server && medusa-admin bundle" 36 | }, 37 | "dependencies": { 38 | "@tolgee/react": "^5.25.0" 39 | }, 40 | "devDependencies": { 41 | "@babel/cli": "^7.14.3", 42 | "@babel/core": "^7.14.3", 43 | "@babel/preset-typescript": "^7.21.4", 44 | "@medusajs/medusa-cli": "latest", 45 | "@stdlib/number-float64-base-normalize": "0.0.8", 46 | "@types/express": "^4.17.13", 47 | "@types/jest": "^27.4.0", 48 | "@types/mime": "1.3.5", 49 | "@types/node": "^17.0.8", 50 | "babel-preset-medusa-package": "^1.1.19", 51 | "cross-env": "^7.0.3", 52 | "eslint": "^6.8.0", 53 | "jest": "^27.3.1", 54 | "rimraf": "^3.0.2", 55 | "ts-jest": "^27.0.7", 56 | "ts-loader": "^9.2.6", 57 | "typescript": "^4.5.2" 58 | }, 59 | "peerDependencies": { 60 | "@medusajs/medusa": "^1.20.5", 61 | "@tanstack/react-query": "4.22.0", 62 | "@tolgee/react": "^5.25.0", 63 | "@medusajs/admin": "^7.1.13", 64 | "@medusajs/cache-redis": "^1.9.1", 65 | "@medusajs/event-bus-local": "latest", 66 | "@medusajs/event-bus-redis": "^1.8.13", 67 | "@medusajs/file-local": "latest", 68 | "@medusajs/ui": "^3.0.0", 69 | "body-parser": "^1.19.0", 70 | "cors": "^2.8.5", 71 | "dotenv": "16.3.1", 72 | "express": "^4.17.2", 73 | "medusa-interfaces": "latest", 74 | "prism-react-renderer": "^2.0.4", 75 | "typeorm": "^0.3.16" 76 | }, 77 | "jest": { 78 | "globals": { 79 | "ts-jest": { 80 | "tsconfig": "tsconfig.spec.json" 81 | } 82 | }, 83 | "moduleFileExtensions": [ 84 | "js", 85 | "json", 86 | "ts" 87 | ], 88 | "testPathIgnorePatterns": [ 89 | "/node_modules/", 90 | "/node_modules/" 91 | ], 92 | "rootDir": "src", 93 | "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|js)$", 94 | "transform": { 95 | ".ts": "ts-jest" 96 | }, 97 | "collectCoverageFrom": [ 98 | "**/*.(t|j)s" 99 | ], 100 | "coverageDirectory": "./coverage", 101 | "testEnvironment": "node" 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /data/seed-onboarding.json: -------------------------------------------------------------------------------- 1 | { 2 | "store": { 3 | "currencies": [ 4 | "eur", 5 | "usd" 6 | ] 7 | }, 8 | "users": [], 9 | "regions": [ 10 | { 11 | "id": "test-region-eu", 12 | "name": "EU", 13 | "currency_code": "eur", 14 | "tax_rate": 0, 15 | "payment_providers": [ 16 | "manual" 17 | ], 18 | "fulfillment_providers": [ 19 | "manual" 20 | ], 21 | "countries": [ 22 | "gb", 23 | "de", 24 | "dk", 25 | "se", 26 | "fr", 27 | "es", 28 | "it" 29 | ] 30 | }, 31 | { 32 | "id": "test-region-na", 33 | "name": "NA", 34 | "currency_code": "usd", 35 | "tax_rate": 0, 36 | "payment_providers": [ 37 | "manual" 38 | ], 39 | "fulfillment_providers": [ 40 | "manual" 41 | ], 42 | "countries": [ 43 | "us", 44 | "ca" 45 | ] 46 | } 47 | ], 48 | "shipping_options": [ 49 | { 50 | "name": "PostFake Standard", 51 | "region_id": "test-region-eu", 52 | "provider_id": "manual", 53 | "data": { 54 | "id": "manual-fulfillment" 55 | }, 56 | "price_type": "flat_rate", 57 | "amount": 1000 58 | }, 59 | { 60 | "name": "PostFake Express", 61 | "region_id": "test-region-eu", 62 | "provider_id": "manual", 63 | "data": { 64 | "id": "manual-fulfillment" 65 | }, 66 | "price_type": "flat_rate", 67 | "amount": 1500 68 | }, 69 | { 70 | "name": "PostFake Return", 71 | "region_id": "test-region-eu", 72 | "provider_id": "manual", 73 | "data": { 74 | "id": "manual-fulfillment" 75 | }, 76 | "price_type": "flat_rate", 77 | "is_return": true, 78 | "amount": 1000 79 | }, 80 | { 81 | "name": "I want to return it myself", 82 | "region_id": "test-region-eu", 83 | "provider_id": "manual", 84 | "data": { 85 | "id": "manual-fulfillment" 86 | }, 87 | "price_type": "flat_rate", 88 | "is_return": true, 89 | "amount": 0 90 | }, 91 | { 92 | "name": "FakeEx Standard", 93 | "region_id": "test-region-na", 94 | "provider_id": "manual", 95 | "data": { 96 | "id": "manual-fulfillment" 97 | }, 98 | "price_type": "flat_rate", 99 | "amount": 800 100 | }, 101 | { 102 | "name": "FakeEx Express", 103 | "region_id": "test-region-na", 104 | "provider_id": "manual", 105 | "data": { 106 | "id": "manual-fulfillment" 107 | }, 108 | "price_type": "flat_rate", 109 | "amount": 1200 110 | }, 111 | { 112 | "name": "FakeEx Return", 113 | "region_id": "test-region-na", 114 | "provider_id": "manual", 115 | "data": { 116 | "id": "manual-fulfillment" 117 | }, 118 | "price_type": "flat_rate", 119 | "is_return": true, 120 | "amount": 800 121 | }, 122 | { 123 | "name": "I want to return it myself", 124 | "region_id": "test-region-na", 125 | "provider_id": "manual", 126 | "data": { 127 | "id": "manual-fulfillment" 128 | }, 129 | "price_type": "flat_rate", 130 | "is_return": true, 131 | "amount": 0 132 | } 133 | ], 134 | "products": [], 135 | "categories": [], 136 | "publishable_api_keys": [ 137 | { 138 | "title": "Development" 139 | } 140 | ] 141 | } -------------------------------------------------------------------------------- /src/services/translation-management.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TransactionBaseService, 3 | MedusaContainer, 4 | Product, 5 | } from "@medusajs/medusa"; 6 | import { AxiosInstance, default as axios } from "axios"; 7 | import { MedusaError } from "@medusajs/utils"; 8 | 9 | type Language = { 10 | label: string; 11 | tag: string; 12 | }; 13 | 14 | type MutlilingualTolgeeConfig = { 15 | defaultLanguage?: string; 16 | availableLanguages?: Language[]; 17 | productsKeys?: string[]; 18 | projectId?: string; 19 | apiKey: string; 20 | baseURL?: string; 21 | }; 22 | 23 | class TranslationManagementService extends TransactionBaseService { 24 | protected client_: AxiosInstance; 25 | readonly options_: MutlilingualTolgeeConfig; 26 | 27 | constructor(container: MedusaContainer, config: MutlilingualTolgeeConfig) { 28 | super(container); 29 | 30 | this.client_ = axios.create({ 31 | baseURL: `${config.baseURL}/v2/projects/${config.projectId}`, 32 | headers: { 33 | Accept: "application/json", 34 | "X-API-Key": config.apiKey, 35 | }, 36 | maxBodyLength: Infinity, 37 | }); 38 | 39 | this.options_ = { 40 | ...config, 41 | defaultLanguage: config.defaultLanguage ?? "en", 42 | availableLanguages: config.availableLanguages ?? [ 43 | { label: "English", tag: "en" }, 44 | ], 45 | productsKeys: config.productsKeys ?? ["title", "subtitle", "description"], 46 | }; 47 | } 48 | 49 | getDefaultLanguage() { 50 | return this.options_.defaultLanguage; 51 | } 52 | 53 | getAvailableLanguages() { 54 | return this.options_.availableLanguages; 55 | } 56 | 57 | async getNamespaceKeys(productId: string): Promise { 58 | try { 59 | const response = await this.client_.get( 60 | `/keys/select?filterNamespace=${productId}` 61 | ); 62 | 63 | return response.data.ids; 64 | } catch (error) { 65 | throw new MedusaError( 66 | MedusaError.Types.UNEXPECTED_STATE, 67 | `Failed to fetch namespace keys for product ${productId}: ${error.message}` 68 | ); 69 | } 70 | } 71 | 72 | async getKeyName(keyId: string): Promise { 73 | try { 74 | const response = await this.client_.get(`/keys/${keyId}`); 75 | 76 | return response.data.name; 77 | } catch (error) { 78 | throw new MedusaError( 79 | MedusaError.Types.UNEXPECTED_STATE, 80 | `Failed to fetch key name for key ID ${keyId}: ${error.message}` 81 | ); 82 | } 83 | } 84 | 85 | async getProductTranslationKeys( 86 | productId: string 87 | ): Promise { 88 | const ids = await this.getNamespaceKeys(productId); 89 | 90 | return await Promise.all(ids.map((keyId) => this.getKeyName(keyId))); 91 | } 92 | 93 | async createNewKeyWithTranslation( 94 | productId: string, 95 | keyName: string, 96 | translation: string 97 | ): Promise { 98 | try { 99 | const response = await this.client_.post(`/keys`, { 100 | name: `${productId}.${keyName}`, 101 | namespace: productId, 102 | translations: { [this.options_.defaultLanguage]: translation }, 103 | }); 104 | 105 | return response.data; 106 | } catch (error) { 107 | throw new MedusaError( 108 | MedusaError.Types.UNEXPECTED_STATE, 109 | `Failed to create new key with translation for ${keyName}: ${error.message}` 110 | ); 111 | } 112 | } 113 | 114 | async createProductTranslations( 115 | productId: string, 116 | product: Product 117 | ): Promise { 118 | const results = []; 119 | 120 | try { 121 | for (const productKey of this.options_.productsKeys) { 122 | const result = await this.createNewKeyWithTranslation( 123 | productId, 124 | productKey, 125 | product[productKey] 126 | ); 127 | results.push(result); 128 | } 129 | } catch (error) { 130 | console.error('Product already translated or error creating translations.'); 131 | } 132 | 133 | return results; 134 | } 135 | 136 | async deleteProductTranslations(productId: string): Promise { 137 | const productTranslationKeys = await this.getNamespaceKeys(productId); 138 | 139 | try { 140 | const response = await this.client_.delete( 141 | `/keys/${productTranslationKeys}` 142 | ); 143 | 144 | return response.data; 145 | } catch (error) { 146 | throw new MedusaError( 147 | MedusaError.Types.UNEXPECTED_STATE, 148 | `Failed to delete product translations for product ${productId}: ${error.message}` 149 | ); 150 | } 151 | } 152 | } 153 | 154 | export default TranslationManagementService; 155 | -------------------------------------------------------------------------------- /src/admin/components/TranslationManagement/TranslationManagement.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { 3 | useAdminCustomQuery, 4 | useAdminCustomPost, 5 | useAdminCreateBatchJob, 6 | } from "medusa-react"; 7 | import { Table, Select, Button } from "@medusajs/ui"; 8 | import formatKeyName from "../../utils/formatKeyName"; 9 | import { useTranslate } from "@tolgee/react"; 10 | 11 | import TranslationSyncStatus from "../TranslationSyncStatus"; 12 | import { 13 | Props, 14 | RequestQuery, 15 | ResponseData, 16 | TranslationRequest, 17 | TranslationResponse, 18 | } from "./types"; 19 | 20 | const TranslationManagement = ({ 21 | product, 22 | notify, 23 | availableLanguages, 24 | defaultLanguage, 25 | handleLanguageChange, 26 | refreshObserver, 27 | refreshKey, 28 | }: Props) => { 29 | const { t } = useTranslate(product.id); 30 | const [keyNames, setKeyNames] = useState([]); 31 | const [batchJobId, setBatchJobId] = useState(null); 32 | const { mutate: syncTranslation, isLoading: syncing } = 33 | useAdminCreateBatchJob(); 34 | 35 | const { mutate: addTranslation, isLoading: adding } = useAdminCustomPost< 36 | TranslationRequest, 37 | TranslationResponse 38 | >(`/admin/product-translation/${product.id}`, ["productTranslation"]); 39 | 40 | const { data } = useAdminCustomQuery( 41 | `/admin/product-translation/${product.id}`, 42 | ["keyNames", refreshKey] 43 | ); 44 | 45 | useEffect(() => { 46 | if (data) { 47 | setKeyNames(data.keyNames); 48 | } 49 | }, [data]); 50 | 51 | const addProductTranslation = () => { 52 | addTranslation( 53 | { 54 | product: product, 55 | }, 56 | { 57 | onSuccess: () => { 58 | notify.success("success", "Product translations created."); 59 | refreshObserver(); 60 | }, 61 | onError: (error) => { 62 | console.error("Failed to create product translations:", error); 63 | notify.error("error", "Failed to create product translations."); 64 | }, 65 | } 66 | ); 67 | }; 68 | 69 | const syncAllTranslations = () => { 70 | syncTranslation( 71 | { 72 | type: "translation-sync", 73 | context: {}, 74 | dry_run: false, 75 | }, 76 | { 77 | onSuccess: ({ batch_job }) => { 78 | notify.success( 79 | "success", 80 | "Translations sync confirmed and processing." 81 | ); 82 | setBatchJobId(batch_job.id); 83 | }, 84 | onError: (error) => { 85 | console.error("Failed to sync all translations:", error); 86 | notify.error("error", "Failed to sync translations."); 87 | }, 88 | } 89 | ); 90 | }; 91 | 92 | const handleSyncStatus = (status: boolean) => { 93 | refreshObserver(); 94 | }; 95 | 96 | return ( 97 |
98 |
99 |

100 | Translation management 101 |

102 |
103 |
104 |
105 | {keyNames.length > 0 ? ( 106 |
107 | {" "} 108 |
109 |
110 |

111 | To translate a word, press the ALT button and click on the 112 | word in the Value column. 113 |

114 |
115 |
116 |
117 | Select language 118 |
119 |
120 | 135 |
136 |
137 |
138 | 139 | 140 | 141 | Key 142 | Value 143 | 144 | 145 | 146 | {keyNames.map((keyName) => ( 147 | 151 | {formatKeyName(keyName)} 152 | 153 |
154 |

155 | {t( 156 | keyName, 157 | "Not translated (press ALT + click the word)" 158 | )} 159 |

160 |
161 |
162 |
163 | ))} 164 |
165 |
{" "} 166 |
167 | ) : ( 168 |
169 |
The product has no translations yet.
170 |
171 | 179 | 187 |
188 | {batchJobId && ( 189 | 193 | )} 194 |
195 |
196 |
197 | )} 198 |
199 |
200 | ); 201 | }; 202 | 203 | export default TranslationManagement; 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Medusa Multilingual Tolgee Plugin](https://rigby-web.fra1.digitaloceanspaces.com/medusa-multilingual-tolgee.png) 2 | 3 |
4 |

Medusa Multilingual Tolgee Plugin | Rigby

5 |

Translate your product information with ease.

6 | 7 | 8 | 9 | License 10 | 11 | 12 | Support 13 | 14 | 15 | 16 |

17 | Medusa | 18 | Tolgee | 19 | Rigby 20 |

21 |
22 |
23 | 24 | ## About the plugin 25 | 26 | This plugin integrates Medusa eCommerce with Tolgee, an open-source localization platform, to provide an easy translation management solution. It's designed to simplify product data translation without the need for complex CMS or PIM systems. By leveraging Tolgee, users access powerful localization features directly within their Medusa admin panel. 27 | 28 | ![Medusa Multilanguage Tolgee GIF](https://rigby-web.fra1.digitaloceanspaces.com/medusa-multilanguage.gif) 29 | 30 | **Key Features of Tolgee** 31 | 32 | - **In-Context Translating**: Utilize Tolgee’s development tools to translate content directly on the frontend, providing a real-time, intuitive translating experience. 33 | - **Translation Assistance**: Enhance translation accuracy and speed with automatic translation suggestions powered by leading AI services such as DeepL, Google Translate, and AWS Translate. 34 | - **Collaborative Workflows**: Streamline the translation process with features that support collaboration, allowing teams to easily review and approve translations, ensuring consistency and quality. 35 | 36 | Tolgee is all about making the translation process as simple as possible. For more details on the extensive capabilities of Tolgee, visit their official website: [Tolgee.io](https://tolgee.io/) 37 | 38 | ## Plugin features 39 | 40 | | Feature | Status | 41 | |------------------------------------------------|------------| 42 | | Admin widget to manage product translations | ✅ | 43 | | Add product translations | ✅ | 44 | | Sync all product with Tolgee | ✅ | 45 | | Automatically add translations when product is added | ✅ | 46 | | Automatically remove translations when product is removed | ✅ | 47 | | Support for collections | Coming soon | 48 | | Support for categories | Coming soon | 49 | | Support for nested product data eg. variant data | Coming soon | 50 | | Support for custom attributes | Coming soon | 51 | 52 | 53 | ## How to use 54 | 55 | #### Set up your Tolgee project 56 | 57 | Before configuring the Medusa plugin, ensure your Tolgee project is ready. You can either set up an open-source version of Tolgee on your own infrastructure or opt for the managed cloud version offered by Tolgee. Obtain your project ID from the project's dashboard URL (e.g., `https://app.tolgee.io/projects/YOUR_PROJECT_ID`). 58 | 59 | #### Install the plugin 60 | 61 | ```javascript 62 | npm install medusa-multilingual-tolgee 63 | ``` 64 | 65 | or 66 | 67 | ```javascript 68 | yarn add medusa-mulitilingual-tolgee 69 | ``` 70 | 71 | #### Add plugin configurations to medusa-config.js 72 | 73 | Once your Tolgee project is set up, add the plugin configuration to your Medusa store by modifying the `medusa-config.js` file. Here's what you need to include: 74 | 75 | ```javascript 76 | const plugins = [ 77 | { 78 | resolve: `medusa-multilingual-tolgee`, 79 | options: { 80 | baseURL: process.env.MEDUSA_ADMIN_TOLGEE_API_URL, 81 | apiKey: process.env.MEDUSA_ADMIN_TOLGEE_API_KEY, 82 | defaultLanguage: "en", 83 | availableLanguages: [ 84 | { label: "English", tag: "en" }, 85 | { label: "German", tag: "de" }, 86 | { label: "Polish", tag: "pl" }, 87 | ], 88 | productsKeys: ["title", "subtitle", "description"], 89 | projectId: "your_tolgee_project_id", 90 | enableUI: true, 91 | }, 92 | }, 93 | ]; 94 | ``` 95 | 96 | Configuration options: 97 | 98 | - **defaultLanguage**: This is the default language for your translations. By default, set to "en" for English, but it can be adjusted based on your primary audience. 99 | - **availableLanguages**: This array contains objects defining the languages you want to support. Each object should have a **`label`**, which is the display name of the language, and a **`tag`**, which is the language code (as defined in your Tolgee project). Make sure these tags match the language tags in your Tolgee project. 100 | - **productsKeys**: Specify which properties of the Medusa product data should be translatable. Common keys include **`"title"`**, **`"subtitle"`**, and **`"description"`**, but you can specify any other fields you need translated. 101 | - **projectId**: Your Tolgee project ID, which you can find in the URL of your project dashboard on the Tolgee platform. 102 | - **enableUI**: Set this to **`true`** to enable the translation management UI components within the Medusa admin. This will allow users to manage translations directly from the Medusa admin panel. 103 | 104 | #### Set Environment Variables in Medusa 105 | 106 | ```javascript 107 | MEDUSA_ADMIN_TOLGEE_API_URL=your_tolgee_app_url 108 | MEDUSA_ADMIN_TOLGEE_API_KEY=your_tolgee_api_key 109 | ``` 110 | 111 | Explanation of Variables 112 | 113 | - **`MEDUSA_ADMIN_TOLGEE_API_URL`**: This is the base URL where your Tolgee instance is hosted. If you are using the Tolgee cloud service, this will be **`https://app.tolgee.io`**. If you have a self-hosted instance, replace this URL with the URL of your own deployment. 114 | - **`MEDUSA_ADMIN_TOLGEE_API_KEY`**: You can find or generate a new API key by navigating to /account/apiKeys within your Tolgee dashboard. If you haven't generated an API key yet, create one by following the prompts in the Tolgee interface. 115 | 116 | #### Sync all your products with Tolgee 117 | 118 | After configuring your environment variables and setting up the plugin, it's time to synchronize your product data with Tolgee to enable translations across your e-commerce platform. Here's how to complete the synchronization process and start translating your products: 119 | 120 | **Restart Medusa**: First, ensure that all your changes are saved, then restart your Medusa server to apply the new configuration settings. This ensures that all components are loaded correctly, including the newly configured translation management plugin. 121 | 122 | **Access the Translation Management Section**: Navigate to the product edit page within your Medusa admin panel. Here's what you need to do: 123 | 124 | **Scroll to the Translation Management Section**: On the product edit page, scroll down until you find a new section labeled "Translation Management". This section is added by the **`medusa-multilingual-tolgee`** plugin and provides the tools necessary for managing product translations. 125 | 126 | ![Medusa Multilingual Tolgee Plugin](https://rigby-web.fra1.digitaloceanspaces.com/translation-management-not-synced.png) 127 | 128 | **Initiate the Sync Process**: Click on the "Sync all translations" button within the Translation Management section. This action triggers a batch job that communicates with Tolgee to create translations for existing products. 129 | 130 | **Wait for Completion**: After clicking the sync button, the process may take some time depending on the number of products and the complexity of the translations. 131 | 132 | ![Medusa Multilingual Tolgee Plugin](https://rigby-web.fra1.digitaloceanspaces.com/translation-management-synced.png) 133 | 134 | Congratulations! Your configuration is now complete, and you can start translating all of your products. 🎉 135 | 136 | If you want to translate a word, press the ALT button and click on the word in the Value column. 137 | 138 | ## How to use it on the frontend 139 | 140 | -**Next.js Pages Router**: [Tolgee Pages Router](https://tolgee.io/js-sdk/integrations/react/next/pages-router) 141 | 142 | -**Next.js App Router**: [Tolgee App Router](https://tolgee.io/js-sdk/integrations/react/next/app-router) 143 | 144 | -**Step-by-step guide of using it with Medusa storefront**: Coming soon 145 | 146 | ## Need help? 147 | 148 | If you have any questions, need help with installing or configuring the plugin, or require assistance with your Medusa project—we are here to help! 149 | 150 | #### About us 151 | 152 | Rigby Medusa Expert 153 | 154 | We are battle-tested Medusa.js Experts & JavaScript Masters - Our software house specializes in B2B & Multi-Vendor Marketplace eCommerce development. 155 | 156 | #### How can we help you? 157 | 158 | - **Consulting in the field of strategy development** 159 | - **Composable eCommerce development in Medusa.js** 160 | - **System maintenance and long-term support** 161 | - **Support in ongoing Medusa projects** 162 | - **Medusa Plugin development** 163 | - **Ecommerce & data migration** 164 | 165 | Check out our project featured on Medusa: https://medusajs.com/blog/patyna/ 166 | 167 | #### Contact us 168 | 169 | 💻 https://rigbyjs.com/en#contact 170 | 171 | 📧 hello@rigbyjs.com 172 | 173 | ## Useful Links 174 | 175 | - [Rigby blog](https://rigbyjs.com/en/blog) 176 | - [Medusa website](https://medusajs.com) 177 | - [Community Discord](https://discord.gg/medusajs) 178 | - [Medusa repo](https://github.com/medusajs/medusa/blob/develop/LICENSE) 179 | - [Medusa Docs](https://github.com/medusajs/medusa) 180 | - [Tolgee website](https://tolgee.io/) 181 | - [Tolgee doc](https://tolgee.io/api) 182 | 183 | ## License 184 | 185 | Licensed under the [MIT License](https://github.com/rigby-sh/medusa-multilingual-tolgee/blob/main/LICENSE). 186 | -------------------------------------------------------------------------------- /data/seed.json: -------------------------------------------------------------------------------- 1 | { 2 | "store": { 3 | "currencies": [ 4 | "eur", 5 | "usd" 6 | ] 7 | }, 8 | "users": [ 9 | { 10 | "email": "admin@medusa-test.com", 11 | "password": "supersecret" 12 | } 13 | ], 14 | "regions": [ 15 | { 16 | "id": "test-region-eu", 17 | "name": "EU", 18 | "currency_code": "eur", 19 | "tax_rate": 0, 20 | "payment_providers": [ 21 | "manual" 22 | ], 23 | "fulfillment_providers": [ 24 | "manual" 25 | ], 26 | "countries": [ 27 | "gb", 28 | "de", 29 | "dk", 30 | "se", 31 | "fr", 32 | "es", 33 | "it" 34 | ] 35 | }, 36 | { 37 | "id": "test-region-na", 38 | "name": "NA", 39 | "currency_code": "usd", 40 | "tax_rate": 0, 41 | "payment_providers": [ 42 | "manual" 43 | ], 44 | "fulfillment_providers": [ 45 | "manual" 46 | ], 47 | "countries": [ 48 | "us", 49 | "ca" 50 | ] 51 | } 52 | ], 53 | "shipping_options": [ 54 | { 55 | "name": "PostFake Standard", 56 | "region_id": "test-region-eu", 57 | "provider_id": "manual", 58 | "data": { 59 | "id": "manual-fulfillment" 60 | }, 61 | "price_type": "flat_rate", 62 | "amount": 1000 63 | }, 64 | { 65 | "name": "PostFake Express", 66 | "region_id": "test-region-eu", 67 | "provider_id": "manual", 68 | "data": { 69 | "id": "manual-fulfillment" 70 | }, 71 | "price_type": "flat_rate", 72 | "amount": 1500 73 | }, 74 | { 75 | "name": "PostFake Return", 76 | "region_id": "test-region-eu", 77 | "provider_id": "manual", 78 | "data": { 79 | "id": "manual-fulfillment" 80 | }, 81 | "price_type": "flat_rate", 82 | "is_return": true, 83 | "amount": 1000 84 | }, 85 | { 86 | "name": "I want to return it myself", 87 | "region_id": "test-region-eu", 88 | "provider_id": "manual", 89 | "data": { 90 | "id": "manual-fulfillment" 91 | }, 92 | "price_type": "flat_rate", 93 | "is_return": true, 94 | "amount": 0 95 | }, 96 | { 97 | "name": "FakeEx Standard", 98 | "region_id": "test-region-na", 99 | "provider_id": "manual", 100 | "data": { 101 | "id": "manual-fulfillment" 102 | }, 103 | "price_type": "flat_rate", 104 | "amount": 800 105 | }, 106 | { 107 | "name": "FakeEx Express", 108 | "region_id": "test-region-na", 109 | "provider_id": "manual", 110 | "data": { 111 | "id": "manual-fulfillment" 112 | }, 113 | "price_type": "flat_rate", 114 | "amount": 1200 115 | }, 116 | { 117 | "name": "FakeEx Return", 118 | "region_id": "test-region-na", 119 | "provider_id": "manual", 120 | "data": { 121 | "id": "manual-fulfillment" 122 | }, 123 | "price_type": "flat_rate", 124 | "is_return": true, 125 | "amount": 800 126 | }, 127 | { 128 | "name": "I want to return it myself", 129 | "region_id": "test-region-na", 130 | "provider_id": "manual", 131 | "data": { 132 | "id": "manual-fulfillment" 133 | }, 134 | "price_type": "flat_rate", 135 | "is_return": true, 136 | "amount": 0 137 | } 138 | ], 139 | "products": [ 140 | { 141 | "title": "Medusa T-Shirt", 142 | "categories": [ 143 | { 144 | "id": "pcat_shirts" 145 | } 146 | ], 147 | "subtitle": null, 148 | "description": "Reimagine the feeling of a classic T-shirt. With our cotton T-shirts, everyday essentials no longer have to be ordinary.", 149 | "handle": "t-shirt", 150 | "is_giftcard": false, 151 | "weight": 400, 152 | "images": [ 153 | "https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-black-front.png", 154 | "https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-black-back.png", 155 | "https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-white-front.png", 156 | "https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-white-back.png" 157 | ], 158 | "options": [ 159 | { 160 | "title": "Size", 161 | "values": [ 162 | "S", 163 | "M", 164 | "L", 165 | "XL" 166 | ] 167 | }, 168 | { 169 | "title": "Color", 170 | "values": [ 171 | "Black", 172 | "White" 173 | ] 174 | } 175 | ], 176 | "variants": [ 177 | { 178 | "title": "S / Black", 179 | "prices": [ 180 | { 181 | "currency_code": "eur", 182 | "amount": 1950 183 | }, 184 | { 185 | "currency_code": "usd", 186 | "amount": 2200 187 | } 188 | ], 189 | "options": [ 190 | { 191 | "value": "S" 192 | }, 193 | { 194 | "value": "Black" 195 | } 196 | ], 197 | "inventory_quantity": 100, 198 | "manage_inventory": true 199 | }, 200 | { 201 | "title": "S / White", 202 | "prices": [ 203 | { 204 | "currency_code": "eur", 205 | "amount": 1950 206 | }, 207 | { 208 | "currency_code": "usd", 209 | "amount": 2200 210 | } 211 | ], 212 | "options": [ 213 | { 214 | "value": "S" 215 | }, 216 | { 217 | "value": "White" 218 | } 219 | ], 220 | "inventory_quantity": 100, 221 | "manage_inventory": true 222 | }, 223 | { 224 | "title": "M / Black", 225 | "prices": [ 226 | { 227 | "currency_code": "eur", 228 | "amount": 1950 229 | }, 230 | { 231 | "currency_code": "usd", 232 | "amount": 2200 233 | } 234 | ], 235 | "options": [ 236 | { 237 | "value": "M" 238 | }, 239 | { 240 | "value": "Black" 241 | } 242 | ], 243 | "inventory_quantity": 100, 244 | "manage_inventory": true 245 | }, 246 | { 247 | "title": "M / White", 248 | "prices": [ 249 | { 250 | "currency_code": "eur", 251 | "amount": 1950 252 | }, 253 | { 254 | "currency_code": "usd", 255 | "amount": 2200 256 | } 257 | ], 258 | "options": [ 259 | { 260 | "value": "M" 261 | }, 262 | { 263 | "value": "White" 264 | } 265 | ], 266 | "inventory_quantity": 100, 267 | "manage_inventory": true 268 | }, 269 | { 270 | "title": "L / Black", 271 | "prices": [ 272 | { 273 | "currency_code": "eur", 274 | "amount": 1950 275 | }, 276 | { 277 | "currency_code": "usd", 278 | "amount": 2200 279 | } 280 | ], 281 | "options": [ 282 | { 283 | "value": "L" 284 | }, 285 | { 286 | "value": "Black" 287 | } 288 | ], 289 | "inventory_quantity": 100, 290 | "manage_inventory": true 291 | }, 292 | { 293 | "title": "L / White", 294 | "prices": [ 295 | { 296 | "currency_code": "eur", 297 | "amount": 1950 298 | }, 299 | { 300 | "currency_code": "usd", 301 | "amount": 2200 302 | } 303 | ], 304 | "options": [ 305 | { 306 | "value": "L" 307 | }, 308 | { 309 | "value": "White" 310 | } 311 | ], 312 | "inventory_quantity": 100, 313 | "manage_inventory": true 314 | }, 315 | { 316 | "title": "XL / Black", 317 | "prices": [ 318 | { 319 | "currency_code": "eur", 320 | "amount": 1950 321 | }, 322 | { 323 | "currency_code": "usd", 324 | "amount": 2200 325 | } 326 | ], 327 | "options": [ 328 | { 329 | "value": "XL" 330 | }, 331 | { 332 | "value": "Black" 333 | } 334 | ], 335 | "inventory_quantity": 100, 336 | "manage_inventory": true 337 | }, 338 | { 339 | "title": "XL / White", 340 | "prices": [ 341 | { 342 | "currency_code": "eur", 343 | "amount": 1950 344 | }, 345 | { 346 | "currency_code": "usd", 347 | "amount": 2200 348 | } 349 | ], 350 | "options": [ 351 | { 352 | "value": "XL" 353 | }, 354 | { 355 | "value": "White" 356 | } 357 | ], 358 | "inventory_quantity": 100, 359 | "manage_inventory": true 360 | } 361 | ] 362 | }, 363 | { 364 | "title": "Medusa Sweatshirt", 365 | "categories": [ 366 | { 367 | "id": "pcat_shirts" 368 | } 369 | ], 370 | "subtitle": null, 371 | "description": "Reimagine the feeling of a classic sweatshirt. With our cotton sweatshirt, everyday essentials no longer have to be ordinary.", 372 | "handle": "sweatshirt", 373 | "is_giftcard": false, 374 | "weight": 400, 375 | "images": [ 376 | "https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatshirt-vintage-front.png", 377 | "https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatshirt-vintage-back.png" 378 | ], 379 | "options": [ 380 | { 381 | "title": "Size", 382 | "values": [ 383 | "S", 384 | "M", 385 | "L", 386 | "XL" 387 | ] 388 | } 389 | ], 390 | "variants": [ 391 | { 392 | "title": "S", 393 | "prices": [ 394 | { 395 | "currency_code": "eur", 396 | "amount": 2950 397 | }, 398 | { 399 | "currency_code": "usd", 400 | "amount": 3350 401 | } 402 | ], 403 | "options": [ 404 | { 405 | "value": "S" 406 | } 407 | ], 408 | "inventory_quantity": 100, 409 | "manage_inventory": true 410 | }, 411 | { 412 | "title": "M", 413 | "prices": [ 414 | { 415 | "currency_code": "eur", 416 | "amount": 2950 417 | }, 418 | { 419 | "currency_code": "usd", 420 | "amount": 3350 421 | } 422 | ], 423 | "options": [ 424 | { 425 | "value": "M" 426 | } 427 | ], 428 | "inventory_quantity": 100, 429 | "manage_inventory": true 430 | }, 431 | { 432 | "title": "L", 433 | "prices": [ 434 | { 435 | "currency_code": "eur", 436 | "amount": 2950 437 | }, 438 | { 439 | "currency_code": "usd", 440 | "amount": 3350 441 | } 442 | ], 443 | "options": [ 444 | { 445 | "value": "L" 446 | } 447 | ], 448 | "inventory_quantity": 100, 449 | "manage_inventory": true 450 | }, 451 | { 452 | "title": "XL", 453 | "prices": [ 454 | { 455 | "currency_code": "eur", 456 | "amount": 2950 457 | }, 458 | { 459 | "currency_code": "usd", 460 | "amount": 3350 461 | } 462 | ], 463 | "options": [ 464 | { 465 | "value": "XL" 466 | } 467 | ], 468 | "inventory_quantity": 100, 469 | "manage_inventory": true 470 | } 471 | ] 472 | }, 473 | { 474 | "title": "Medusa Sweatpants", 475 | "categories": [ 476 | { 477 | "id": "pcat_pants" 478 | } 479 | ], 480 | "subtitle": null, 481 | "description": "Reimagine the feeling of classic sweatpants. With our cotton sweatpants, everyday essentials no longer have to be ordinary.", 482 | "handle": "sweatpants", 483 | "is_giftcard": false, 484 | "weight": 400, 485 | "images": [ 486 | "https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png", 487 | "https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-back.png" 488 | ], 489 | "options": [ 490 | { 491 | "title": "Size", 492 | "values": [ 493 | "S", 494 | "M", 495 | "L", 496 | "XL" 497 | ] 498 | } 499 | ], 500 | "variants": [ 501 | { 502 | "title": "S", 503 | "prices": [ 504 | { 505 | "currency_code": "eur", 506 | "amount": 2950 507 | }, 508 | { 509 | "currency_code": "usd", 510 | "amount": 3350 511 | } 512 | ], 513 | "options": [ 514 | { 515 | "value": "S" 516 | } 517 | ], 518 | "inventory_quantity": 100, 519 | "manage_inventory": true 520 | }, 521 | { 522 | "title": "M", 523 | "prices": [ 524 | { 525 | "currency_code": "eur", 526 | "amount": 2950 527 | }, 528 | { 529 | "currency_code": "usd", 530 | "amount": 3350 531 | } 532 | ], 533 | "options": [ 534 | { 535 | "value": "M" 536 | } 537 | ], 538 | "inventory_quantity": 100, 539 | "manage_inventory": true 540 | }, 541 | { 542 | "title": "L", 543 | "prices": [ 544 | { 545 | "currency_code": "eur", 546 | "amount": 2950 547 | }, 548 | { 549 | "currency_code": "usd", 550 | "amount": 3350 551 | } 552 | ], 553 | "options": [ 554 | { 555 | "value": "L" 556 | } 557 | ], 558 | "inventory_quantity": 100, 559 | "manage_inventory": true 560 | }, 561 | { 562 | "title": "XL", 563 | "prices": [ 564 | { 565 | "currency_code": "eur", 566 | "amount": 2950 567 | }, 568 | { 569 | "currency_code": "usd", 570 | "amount": 3350 571 | } 572 | ], 573 | "options": [ 574 | { 575 | "value": "XL" 576 | } 577 | ], 578 | "inventory_quantity": 100, 579 | "manage_inventory": true 580 | } 581 | ] 582 | }, 583 | { 584 | "title": "Medusa Shorts", 585 | "categories": [ 586 | { 587 | "id": "pcat_merch" 588 | } 589 | ], 590 | "subtitle": null, 591 | "description": "Reimagine the feeling of classic shorts. With our cotton shorts, everyday essentials no longer have to be ordinary.", 592 | "handle": "shorts", 593 | "is_giftcard": false, 594 | "weight": 400, 595 | "images": [ 596 | "https://medusa-public-images.s3.eu-west-1.amazonaws.com/shorts-vintage-front.png", 597 | "https://medusa-public-images.s3.eu-west-1.amazonaws.com/shorts-vintage-back.png" 598 | ], 599 | "options": [ 600 | { 601 | "title": "Size", 602 | "values": [ 603 | "S", 604 | "M", 605 | "L", 606 | "XL" 607 | ] 608 | } 609 | ], 610 | "variants": [ 611 | { 612 | "title": "S", 613 | "prices": [ 614 | { 615 | "currency_code": "eur", 616 | "amount": 2500 617 | }, 618 | { 619 | "currency_code": "usd", 620 | "amount": 2850 621 | } 622 | ], 623 | "options": [ 624 | { 625 | "value": "S" 626 | } 627 | ], 628 | "inventory_quantity": 100, 629 | "manage_inventory": true 630 | }, 631 | { 632 | "title": "M", 633 | "prices": [ 634 | { 635 | "currency_code": "eur", 636 | "amount": 2500 637 | }, 638 | { 639 | "currency_code": "usd", 640 | "amount": 2850 641 | } 642 | ], 643 | "options": [ 644 | { 645 | "value": "M" 646 | } 647 | ], 648 | "inventory_quantity": 100, 649 | "manage_inventory": true 650 | }, 651 | { 652 | "title": "L", 653 | "prices": [ 654 | { 655 | "currency_code": "eur", 656 | "amount": 2500 657 | }, 658 | { 659 | "currency_code": "usd", 660 | "amount": 2850 661 | } 662 | ], 663 | "options": [ 664 | { 665 | "value": "L" 666 | } 667 | ], 668 | "inventory_quantity": 100, 669 | "manage_inventory": true 670 | }, 671 | { 672 | "title": "XL", 673 | "prices": [ 674 | { 675 | "currency_code": "eur", 676 | "amount": 2500 677 | }, 678 | { 679 | "currency_code": "usd", 680 | "amount": 2850 681 | } 682 | ], 683 | "options": [ 684 | { 685 | "value": "XL" 686 | } 687 | ], 688 | "inventory_quantity": 100, 689 | "manage_inventory": true 690 | } 691 | ] 692 | }, 693 | { 694 | "title": "Medusa Hoodie", 695 | "categories": [ 696 | { 697 | "id": "pcat_merch" 698 | }, 699 | { 700 | "id": "pcat_hidden_featured" 701 | } 702 | ], 703 | "subtitle": null, 704 | "description": "Reimagine the feeling of a classic hoodie. With our cotton hoodie, everyday essentials no longer have to be ordinary.", 705 | "handle": "hoodie", 706 | "is_giftcard": false, 707 | "weight": 400, 708 | "images": [ 709 | "https://medusa-public-images.s3.eu-west-1.amazonaws.com/black_hoodie_front.png", 710 | "https://medusa-public-images.s3.eu-west-1.amazonaws.com/black_hoodie_back.png" 711 | ], 712 | "options": [ 713 | { 714 | "title": "Size", 715 | "values": [ 716 | "S", 717 | "M", 718 | "L", 719 | "XL" 720 | ] 721 | } 722 | ], 723 | "variants": [ 724 | { 725 | "title": "S", 726 | "prices": [ 727 | { 728 | "currency_code": "eur", 729 | "amount": 3650 730 | }, 731 | { 732 | "currency_code": "usd", 733 | "amount": 4150 734 | } 735 | ], 736 | "options": [ 737 | { 738 | "value": "S" 739 | } 740 | ], 741 | "inventory_quantity": 100, 742 | "manage_inventory": true 743 | }, 744 | { 745 | "title": "M", 746 | "prices": [ 747 | { 748 | "currency_code": "eur", 749 | "amount": 3650 750 | }, 751 | { 752 | "currency_code": "usd", 753 | "amount": 4150 754 | } 755 | ], 756 | "options": [ 757 | { 758 | "value": "M" 759 | } 760 | ], 761 | "inventory_quantity": 100, 762 | "manage_inventory": true 763 | }, 764 | { 765 | "title": "L", 766 | "prices": [ 767 | { 768 | "currency_code": "eur", 769 | "amount": 3650 770 | }, 771 | { 772 | "currency_code": "usd", 773 | "amount": 4150 774 | } 775 | ], 776 | "options": [ 777 | { 778 | "value": "L" 779 | } 780 | ], 781 | "inventory_quantity": 100, 782 | "manage_inventory": true 783 | }, 784 | { 785 | "title": "XL", 786 | "prices": [ 787 | { 788 | "currency_code": "eur", 789 | "amount": 3650 790 | }, 791 | { 792 | "currency_code": "usd", 793 | "amount": 4150 794 | } 795 | ], 796 | "options": [ 797 | { 798 | "value": "XL" 799 | } 800 | ], 801 | "inventory_quantity": 100, 802 | "manage_inventory": true 803 | } 804 | ] 805 | }, 806 | { 807 | "title": "Medusa Longsleeve", 808 | "categories": [ 809 | { 810 | "id": "pcat_shirts" 811 | }, 812 | { 813 | "id": "pcat_hidden_featured" 814 | } 815 | ], 816 | "subtitle": null, 817 | "description": "Reimagine the feeling of a classic longsleeve. With our cotton longsleeve, everyday essentials no longer have to be ordinary.", 818 | "handle": "longsleeve", 819 | "is_giftcard": false, 820 | "weight": 400, 821 | "images": [ 822 | "https://medusa-public-images.s3.eu-west-1.amazonaws.com/ls-black-front.png", 823 | "https://medusa-public-images.s3.eu-west-1.amazonaws.com/ls-black-back.png" 824 | ], 825 | "options": [ 826 | { 827 | "title": "Size", 828 | "values": [ 829 | "S", 830 | "M", 831 | "L", 832 | "XL" 833 | ] 834 | } 835 | ], 836 | "variants": [ 837 | { 838 | "title": "S", 839 | "prices": [ 840 | { 841 | "currency_code": "eur", 842 | "amount": 3650 843 | }, 844 | { 845 | "currency_code": "usd", 846 | "amount": 4150 847 | } 848 | ], 849 | "options": [ 850 | { 851 | "value": "S" 852 | } 853 | ], 854 | "inventory_quantity": 100, 855 | "manage_inventory": true 856 | }, 857 | { 858 | "title": "M", 859 | "prices": [ 860 | { 861 | "currency_code": "eur", 862 | "amount": 3650 863 | }, 864 | { 865 | "currency_code": "usd", 866 | "amount": 4150 867 | } 868 | ], 869 | "options": [ 870 | { 871 | "value": "M" 872 | } 873 | ], 874 | "inventory_quantity": 100, 875 | "manage_inventory": true 876 | }, 877 | { 878 | "title": "L", 879 | "prices": [ 880 | { 881 | "currency_code": "eur", 882 | "amount": 3650 883 | }, 884 | { 885 | "currency_code": "usd", 886 | "amount": 4150 887 | } 888 | ], 889 | "options": [ 890 | { 891 | "value": "L" 892 | } 893 | ], 894 | "inventory_quantity": 100, 895 | "manage_inventory": true 896 | }, 897 | { 898 | "title": "XL", 899 | "prices": [ 900 | { 901 | "currency_code": "eur", 902 | "amount": 3650 903 | }, 904 | { 905 | "currency_code": "usd", 906 | "amount": 4150 907 | } 908 | ], 909 | "options": [ 910 | { 911 | "value": "XL" 912 | } 913 | ], 914 | "inventory_quantity": 100, 915 | "manage_inventory": true 916 | } 917 | ] 918 | }, 919 | { 920 | "title": "Medusa Coffee Mug", 921 | "categories": [ 922 | { 923 | "id": "pcat_merch" 924 | }, 925 | { 926 | "id": "pcat_hidden_featured" 927 | } 928 | ], 929 | "subtitle": null, 930 | "description": "Every programmer's best friend.", 931 | "handle": "coffee-mug", 932 | "is_giftcard": false, 933 | "weight": 400, 934 | "images": [ 935 | "https://medusa-public-images.s3.eu-west-1.amazonaws.com/coffee-mug.png" 936 | ], 937 | "options": [ 938 | { 939 | "title": "Size", 940 | "values": [ 941 | "One Size" 942 | ] 943 | } 944 | ], 945 | "variants": [ 946 | { 947 | "title": "One Size", 948 | "prices": [ 949 | { 950 | "currency_code": "eur", 951 | "amount": 1000 952 | }, 953 | { 954 | "currency_code": "usd", 955 | "amount": 1200 956 | } 957 | ], 958 | "options": [ 959 | { 960 | "value": "One Size" 961 | } 962 | ], 963 | "inventory_quantity": 100, 964 | "manage_inventory": true 965 | } 966 | ] 967 | } 968 | ], 969 | "categories": [ 970 | { 971 | "id": "pcat_pants", 972 | "name": "Pants", 973 | "rank": 0, 974 | "category_children": [], 975 | "handle": "pants" 976 | }, 977 | { 978 | "id": "pcat_shirts", 979 | "name": "Shirts", 980 | "rank": 0, 981 | "category_children": [], 982 | "handle": "shirts" 983 | }, 984 | { 985 | "id": "pcat_merch", 986 | "name": "Merch", 987 | "rank": 0, 988 | "category_children": [], 989 | "handle": "merch" 990 | }, 991 | { 992 | "id": "pcat_hidden_carousel", 993 | "name": "Hidden homepage carousel", 994 | "rank": 0, 995 | "category_children": [], 996 | "handle": "hidden-homepage-carousel" 997 | }, 998 | { 999 | "id": "pcat_hidden_featured", 1000 | "name": "Hidden homepage featured", 1001 | "rank": 0, 1002 | "category_children": [], 1003 | "handle": "hidden-homepage-featured-items" 1004 | } 1005 | ] 1006 | } --------------------------------------------------------------------------------