├── .gitignore ├── .nvmrc ├── .vscode └── settings.json ├── .yarn └── releases │ └── yarn-4.9.1.cjs ├── .yarnrc.yml ├── README.md ├── biome.json ├── package.json ├── packages └── plugins-sdk │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src │ ├── index.ts │ ├── sdk │ │ ├── admin │ │ │ ├── admin-product-reviews.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ └── store │ │ │ ├── index.ts │ │ │ └── store-product-reviews.ts │ └── types │ │ ├── index.ts │ │ ├── product-review-stats.ts │ │ └── product-reviews.ts │ ├── tsconfig.esm.json │ ├── tsconfig.json │ └── tsup.config.ts ├── plugins ├── product-reviews │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src │ │ ├── admin │ │ │ ├── README.md │ │ │ ├── components │ │ │ │ ├── atoms │ │ │ │ │ ├── action-menu.tsx │ │ │ │ │ ├── container.tsx │ │ │ │ │ ├── header.tsx │ │ │ │ │ ├── review-stars.tsx │ │ │ │ │ └── section-row.tsx │ │ │ │ ├── hooks │ │ │ │ │ └── product-review.ts │ │ │ │ └── molecules │ │ │ │ │ ├── ProductReviewDataTable.tsx │ │ │ │ │ ├── ProductReviewDetailsDrawer.tsx │ │ │ │ │ └── ProductReviewResponseDrawer.tsx │ │ │ ├── sdk.ts │ │ │ ├── tsconfig.json │ │ │ ├── vite-env.d.ts │ │ │ └── widgets │ │ │ │ ├── order-details-product-reviews.tsx │ │ │ │ └── product-details-product-reviews.tsx │ │ ├── api │ │ │ ├── admin │ │ │ │ ├── product-review-stats │ │ │ │ │ ├── middlewares.ts │ │ │ │ │ └── route.ts │ │ │ │ └── product-reviews │ │ │ │ │ ├── [id] │ │ │ │ │ ├── response │ │ │ │ │ │ ├── middlewares.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── status │ │ │ │ │ │ ├── middlewares.ts │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── middlewares.ts │ │ │ │ │ └── route.ts │ │ │ ├── middlewares.ts │ │ │ └── store │ │ │ │ ├── product-review-stats │ │ │ │ ├── middlewares.ts │ │ │ │ └── route.ts │ │ │ │ └── product-reviews │ │ │ │ ├── middlewares.ts │ │ │ │ ├── route.ts │ │ │ │ └── uploads │ │ │ │ ├── middlewares.ts │ │ │ │ └── route.ts │ │ ├── jobs │ │ │ └── README.md │ │ ├── links │ │ │ ├── README.md │ │ │ ├── product-review-order-line-item.ts │ │ │ ├── product-review-order.ts │ │ │ ├── product-review-product.ts │ │ │ └── product-review-stats-product.ts │ │ ├── modules │ │ │ └── product-review │ │ │ │ ├── index.ts │ │ │ │ ├── loaders │ │ │ │ └── validate.ts │ │ │ │ ├── migrations │ │ │ │ ├── .snapshot-medusa-product-review.json │ │ │ │ ├── Migration20250212230212.ts │ │ │ │ └── Migration20250421143843.ts │ │ │ │ ├── models │ │ │ │ ├── index.ts │ │ │ │ ├── product-review-image.ts │ │ │ │ ├── product-review-response.ts │ │ │ │ ├── product-review-stats.ts │ │ │ │ └── product-review.ts │ │ │ │ ├── service.ts │ │ │ │ └── types │ │ │ │ ├── common.ts │ │ │ │ ├── index.ts │ │ │ │ └── mutations.ts │ │ ├── providers │ │ │ └── README.md │ │ ├── subscribers │ │ │ └── README.md │ │ └── workflows │ │ │ ├── create-product-review-responses.ts │ │ │ ├── create-product-reviews.ts │ │ │ ├── delete-product-review-responses.ts │ │ │ ├── refresh-product-review-stats.ts │ │ │ ├── steps │ │ │ ├── create-missing-product-review-stats.ts │ │ │ ├── create-product-review-responses.ts │ │ │ ├── create-product-reviews.ts │ │ │ ├── delete-product-review-responses.ts │ │ │ ├── recalculate-product-review-stats.ts │ │ │ ├── update-product-review-response.ts │ │ │ └── update-product-reviews.ts │ │ │ ├── update-product-review-responses.ts │ │ │ ├── update-product-reviews.ts │ │ │ └── upsert-product-reviews.ts │ └── tsconfig.json └── webhooks │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src │ ├── admin │ │ ├── README.md │ │ ├── components │ │ │ ├── atoms │ │ │ │ └── Spinner.tsx │ │ │ ├── fundamentals │ │ │ │ └── status-indicator │ │ │ │ │ └── index.tsx │ │ │ ├── molecules │ │ │ │ ├── Actionables.tsx │ │ │ │ ├── Modal.tsx │ │ │ │ └── WebhooksTable.tsx │ │ │ └── organisms │ │ │ │ └── data-table │ │ │ │ └── index.tsx │ │ ├── hooks │ │ │ ├── use-window-dimensions.tsx │ │ │ └── webhooks │ │ │ │ ├── mutations.ts │ │ │ │ └── queries.ts │ │ ├── lib │ │ │ └── sdk.ts │ │ ├── modals │ │ │ ├── webhook-delete-modal.tsx │ │ │ ├── webhook-modal.tsx │ │ │ └── webhook-test-modal.tsx │ │ ├── routes │ │ │ └── webhooks │ │ │ │ └── page.tsx │ │ ├── tsconfig.json │ │ ├── utils │ │ │ └── error-messages.ts │ │ └── vite-env.d.ts │ ├── api │ │ ├── README.md │ │ ├── admin │ │ │ ├── plugin │ │ │ │ └── route.ts │ │ │ └── webhooks │ │ │ │ ├── [id] │ │ │ │ └── route.ts │ │ │ │ ├── events │ │ │ │ └── route.ts │ │ │ │ ├── middlewares.ts │ │ │ │ ├── route.ts │ │ │ │ └── test-webhook │ │ │ │ └── route.ts │ │ ├── middlewares.ts │ │ └── store │ │ │ └── plugin │ │ │ └── route.ts │ ├── common.ts │ ├── jobs │ │ └── README.md │ ├── links │ │ └── README.md │ ├── modules │ │ ├── README.md │ │ └── webhooks │ │ │ ├── index.ts │ │ │ ├── migrations │ │ │ ├── .snapshot-medusa-webhooks.json │ │ │ └── Migration20250220202023.ts │ │ │ ├── models │ │ │ └── webhooks.ts │ │ │ └── service.ts │ ├── providers │ │ └── README.md │ ├── subscribers │ │ └── README.md │ ├── types │ │ ├── index.d.ts │ │ └── workflow.ts │ └── workflows │ │ ├── README.md │ │ ├── full-webhooks-subscriptions-workflow.ts │ │ ├── get-webhooks-subscriptions-workflow.ts │ │ ├── index.ts │ │ ├── send-webhooks-events-workflow.ts │ │ └── steps │ │ └── process-webhooks-step.ts │ └── tsconfig.json ├── turbo.json ├── v1 ├── .eslintrc.js ├── .gitignore ├── .npmrc ├── .yarn │ └── releases │ │ └── yarn-4.1.1.cjs ├── .yarnrc.yml ├── README.md ├── modules │ └── event-bus-redis │ │ ├── .gitignore │ │ ├── CHANGELOG.md │ │ ├── README.md │ │ ├── jest.config.js │ │ ├── package.json │ │ ├── src │ │ ├── index.ts │ │ ├── initialize │ │ │ └── index.ts │ │ ├── loaders │ │ │ └── index.ts │ │ ├── services │ │ │ ├── __tests__ │ │ │ │ └── event-bus.js │ │ │ └── event-bus-redis.ts │ │ └── types │ │ │ └── index.ts │ │ ├── tsconfig.json │ │ └── tsconfig.spec.json ├── package.json ├── packages │ ├── event-bus-dashboard │ │ ├── README.md │ │ ├── package.json │ │ ├── src │ │ │ └── api │ │ │ │ └── index.ts │ │ └── tsconfig.json │ └── medusa-plugin-webhooks │ │ ├── README.md │ │ ├── package.json │ │ ├── src │ │ ├── admin │ │ │ ├── components │ │ │ │ ├── atoms │ │ │ │ │ └── Spinner.tsx │ │ │ │ ├── fundamentals │ │ │ │ │ └── status-indicator │ │ │ │ │ │ └── index.tsx │ │ │ │ ├── molecules │ │ │ │ │ ├── Actionables.tsx │ │ │ │ │ ├── Modal.tsx │ │ │ │ │ └── WebhooksTable.tsx │ │ │ │ └── organisms │ │ │ │ │ └── data-table │ │ │ │ │ └── index.tsx │ │ │ ├── hooks │ │ │ │ ├── use-window-dimensions.tsx │ │ │ │ └── webhooks │ │ │ │ │ ├── mutations.ts │ │ │ │ │ └── queries.ts │ │ │ ├── modals │ │ │ │ ├── webhook-delete-modal.tsx │ │ │ │ ├── webhook-modal.tsx │ │ │ │ └── webhook-test-modal.tsx │ │ │ ├── settings │ │ │ │ └── webhooks │ │ │ │ │ └── page.tsx │ │ │ └── utils │ │ │ │ └── error-messages.ts │ │ ├── api │ │ │ ├── admin │ │ │ │ └── webhooks │ │ │ │ │ ├── [id] │ │ │ │ │ └── route.ts │ │ │ │ │ ├── events │ │ │ │ │ └── route.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ └── test │ │ │ │ │ └── route.ts │ │ │ └── validators.ts │ │ ├── index.ts │ │ ├── migrations │ │ │ └── 1705957279005-WebhookCreate.ts │ │ ├── models │ │ │ ├── index.ts │ │ │ └── webhook.ts │ │ ├── repositories │ │ │ ├── index.ts │ │ │ └── webhook.ts │ │ ├── services │ │ │ ├── index.ts │ │ │ └── webhook.ts │ │ └── subscribers │ │ │ └── webhook.ts │ │ ├── tsconfig.admin.json │ │ ├── tsconfig.json │ │ └── tsconfig.server.json ├── turbo.json └── yarn.lock └── yarn.lock /.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 | medusa-db.sql 16 | build 17 | .cache 18 | 19 | .yarn/* 20 | !.yarn/patches 21 | !.yarn/plugins 22 | !.yarn/releases 23 | !.yarn/sdks 24 | !.yarn/versions 25 | 26 | .medusa 27 | 28 | .turbo 29 | 30 | .yalc 31 | yalc.lock 32 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "biomejs.biome", 3 | "[typescript]": { 4 | "editor.defaultFormatter": "biomejs.biome" 5 | }, 6 | "[typescriptreact]": { 7 | "editor.defaultFormatter": "biomejs.biome" 8 | }, 9 | "editor.codeActionsOnSave": { 10 | "quickfix.biome": "explicit", 11 | }, 12 | "editor.formatOnSave": true 13 | } 14 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | yarnPath: .yarn/releases/yarn-4.9.1.cjs 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Medusa Plugins Collection 2 | 3 | A collection of plugins for enhancing your Medusa commerce application with additional features and functionalities. 4 | 5 | ## Plugins SDK 6 | 7 | You can use the [Plugins SDK](./packages/plugins-sdk) to interact with the plugins in this collection. The SDK provides a unified way to interact with all plugins through both Store and Admin operations. 8 | Available Plugins 9 | 10 | ## 11 | 12 | ### [Product Reviews](./plugins/product-reviews) 13 | 14 | Add product review capabilities to your Medusa store: 15 | 16 | - Product reviews with ratings 17 | - Review statistics and analytics 18 | - Review moderation workflow (`approved`/`pending`/`flagged`) 19 | - Admin response management 20 | - SDK for Store and Admin operations 21 | 22 | ### [Webhooks](./plugins/webhooks) 23 | 24 | Add webhook functionality to your Medusa e-commerce server: 25 | 26 | - Event-based webhooks for real-time notifications 27 | - Flexible configuration through Medusa's plugin system 28 | - Built-in support for common Medusa events 29 | - Seamless workflow integration 30 | 31 | > See a demo in our [Medusa Starter](https://github.com/lambda-curry/medusa2-starter) 32 | 33 | ## Requirements 34 | 35 | - Medusa >= 2.5.0 36 | - Node >= 20 37 | - yarn@4.6.0 38 | 39 | ## Development 40 | 41 | In order to develop and test the plugins, you need to have a running Medusa instance. You can use our [Medusa Starter](https://github.com/lambda-curry/medusa2-starter) for this purpose. 42 | 43 | ```bash 44 | # Clone the repository 45 | git clone https://github.com/lambda-curry/medusa-plugins.git 46 | # Install dependencies 47 | yarn install 48 | # Test the setup 49 | yarn build 50 | ``` 51 | 52 | ## License 53 | 54 | MIT 55 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.1/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "include": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"], 11 | "ignore": [".turbo", "yarn.lock"] 12 | }, 13 | "organizeImports": { "enabled": true }, 14 | "formatter": { 15 | "enabled": true, 16 | "formatWithErrors": false, 17 | "indentStyle": "space", 18 | "indentWidth": 2, 19 | "lineWidth": 120 20 | }, 21 | "javascript": { 22 | "formatter": { 23 | "quoteStyle": "single", 24 | "semicolons": "always" 25 | } 26 | }, 27 | "linter": { 28 | "enabled": false, 29 | "rules": { 30 | "all": true, 31 | "style": { 32 | "all": true, 33 | "useBlockStatements": "off", 34 | "useNamingConvention": "off", 35 | "noImplicitBoolean": "off", 36 | "noDefaultExport": "off", 37 | "noUnusedTemplateLiteral": "off", 38 | "useFilenamingConvention": "off", 39 | "noNonNullAssertion": "off", 40 | "useExplicitLengthCheck": "off" 41 | }, 42 | "complexity": { 43 | "all": true, 44 | "noForEach": "off", 45 | "useLiteralKeys": "off" 46 | }, 47 | "performance": { 48 | "all": true, 49 | "noAccumulatingSpread": "off" 50 | }, 51 | "suspicious": { 52 | "noConsoleLog": "off", 53 | "noReactSpecificProps": "off" 54 | }, 55 | "correctness": { 56 | "all": true, 57 | "noNodejsModules": "off", 58 | "noUndeclaredDependencies": "off", 59 | "useImportExtensions": "off" 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "medusa-plugins", 3 | "author": "Lambda Curry (https://lambdacurry.dev)", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/lambda-curry/medusa-plugins" 7 | }, 8 | "homepage": "https://github.com/lambda-curry/medusa-plugins", 9 | "scripts": { 10 | "build": "turbo build", 11 | "dev": "turbo dev" 12 | }, 13 | "devDependencies": { 14 | "@biomejs/biome": "^1.9.4", 15 | "turbo": "latest", 16 | "typescript": "^5.7.3" 17 | }, 18 | "engines": { 19 | "node": ">=20" 20 | }, 21 | "workspaces": [ 22 | "./plugins/*", 23 | "./packages/*" 24 | ], 25 | "packageManager": "yarn@4.9.1" 26 | } 27 | -------------------------------------------------------------------------------- /packages/plugins-sdk/.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | 4 | .idea 5 | .DS_Store 6 | 7 | yarn-error.log 8 | 9 | *.tsbuildinfo 10 | 11 | .cache 12 | .turbo -------------------------------------------------------------------------------- /packages/plugins-sdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lambdacurry/medusa-plugins-sdk", 3 | "version": "0.0.5", 4 | "description": "SDK for Medusa plugins", 5 | "author": "Lambda Curry (https://lambdacurry.dev)", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/lambda-curry/medusa-plugins" 10 | }, 11 | "homepage": "https://github.com/lambda-curry/medusa-plugins/tree/main/packages/plugins-sdk", 12 | "files": [ 13 | "dist" 14 | ], 15 | "main": "./dist/cjs/index.js", 16 | "module": "./dist/esm/index.js", 17 | "types": "./dist/esm/index.d.ts", 18 | "exports": { 19 | "import": "./dist/esm/index.js", 20 | "require": "./dist/cjs/index.js", 21 | "types": "./dist/esm/index.d.ts" 22 | }, 23 | "keywords": [ 24 | "medusa", 25 | "plugin", 26 | "sdk", 27 | "medusa-plugins-sdk", 28 | "medusa-v2", 29 | "lambdacurry" 30 | ], 31 | "scripts": { 32 | "build": "rimraf dist && tsc -p tsconfig.json && tsc -p tsconfig.esm.json", 33 | "clean": "rm -rf dist", 34 | "typecheck": "tsc --noEmit", 35 | "lint": "prettier --check \"src/**/*.{ts,tsx}\"", 36 | "format": "prettier --write \"src/**/*.{ts,tsx}\"", 37 | "prepublishOnly": "yarn build", 38 | "dev:publish": "yalc publish" 39 | }, 40 | "devDependencies": { 41 | "@medusajs/types": "^2.5.0", 42 | "prettier": "^3.2.5", 43 | "rimraf": "^6.0.1", 44 | "tsup": "^8.0.2", 45 | "typescript": "^5.7.2", 46 | "yalc": "^1.0.0-pre.53" 47 | }, 48 | "dependencies": { 49 | "@medusajs/js-sdk": "^2.5.0", 50 | "@types/express": "^5.0.0", 51 | "@types/multer": "^1.4.12", 52 | "form-data": "^4.0.2" 53 | }, 54 | "engines": { 55 | "node": ">=20" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /packages/plugins-sdk/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './sdk' 2 | export * from './types' 3 | -------------------------------------------------------------------------------- /packages/plugins-sdk/src/sdk/admin/admin-product-reviews.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '@medusajs/js-sdk'; 2 | import type { 3 | AdminCreateProductReviewResponseDTO, 4 | AdminListProductReviewsQuery, 5 | AdminProductReviewResponse, 6 | AdminUpdateProductReviewResponseDTO, 7 | AdminListProductReviewsResponse, 8 | } from '../../types'; 9 | 10 | export class AdminProductReviewsResource { 11 | constructor(private client: Client) {} 12 | 13 | async list(query: AdminListProductReviewsQuery) { 14 | return this.client.fetch(`/admin/product-reviews`, { 15 | method: 'GET', 16 | query, 17 | }); 18 | } 19 | 20 | async updateStatus(productReviewId: string, status: 'pending' | 'approved' | 'flagged') { 21 | return this.client.fetch(`/admin/product-reviews/${productReviewId}/status`, { 22 | method: 'PUT', 23 | body: { status }, 24 | }); 25 | } 26 | 27 | async createResponse(productReviewId: string, data: AdminCreateProductReviewResponseDTO) { 28 | return this.client.fetch(`/admin/product-reviews/${productReviewId}/response`, { 29 | method: 'POST', 30 | body: data, 31 | }); 32 | } 33 | 34 | async updateResponse(productReviewId: string, data: AdminUpdateProductReviewResponseDTO) { 35 | return this.client.fetch(`/admin/product-reviews/${productReviewId}/response`, { 36 | method: 'PUT', 37 | body: data, 38 | }); 39 | } 40 | 41 | async deleteResponse(productReviewId: string) { 42 | return this.client.fetch(`/admin/product-reviews/${productReviewId}/response`, { 43 | method: 'DELETE', 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/plugins-sdk/src/sdk/admin/index.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '@medusajs/js-sdk'; 2 | import { Admin } from '@medusajs/js-sdk'; 3 | import { AdminProductReviewsResource } from './admin-product-reviews'; 4 | 5 | export class ExtendedAdminSDK extends Admin { 6 | public productReviews: AdminProductReviewsResource; 7 | 8 | constructor(client: Client) { 9 | super(client); 10 | this.productReviews = new AdminProductReviewsResource(client); 11 | } 12 | } -------------------------------------------------------------------------------- /packages/plugins-sdk/src/sdk/index.ts: -------------------------------------------------------------------------------- 1 | import Medusa, { type Config } from '@medusajs/js-sdk'; 2 | import { ExtendedAdminSDK } from './admin'; 3 | import { ExtendedStorefrontSDK } from './store'; 4 | 5 | export class MedusaPluginsSDK extends Medusa { 6 | public admin: ExtendedAdminSDK 7 | public store: ExtendedStorefrontSDK 8 | 9 | constructor(config: Config) { 10 | super(config) 11 | 12 | this.admin = new ExtendedAdminSDK(this.client) 13 | this.store = new ExtendedStorefrontSDK(this.client) 14 | } 15 | } 16 | 17 | export { AdminProductReviewsResource } from './admin/admin-product-reviews' 18 | export { StoreProductReviewsResource } from './store/store-product-reviews' 19 | -------------------------------------------------------------------------------- /packages/plugins-sdk/src/sdk/store/index.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '@medusajs/js-sdk'; 2 | import { Store } from '@medusajs/js-sdk'; 3 | import { StoreProductReviewsResource } from './store-product-reviews'; 4 | 5 | export class ExtendedStorefrontSDK extends Store { 6 | public productReviews: StoreProductReviewsResource; 7 | constructor(client: Client) { 8 | super(client); 9 | this.productReviews = new StoreProductReviewsResource(client); 10 | } 11 | } -------------------------------------------------------------------------------- /packages/plugins-sdk/src/sdk/store/store-product-reviews.ts: -------------------------------------------------------------------------------- 1 | import type { Client, ClientHeaders } from '@medusajs/js-sdk'; 2 | import type { 3 | StoreListProductReviewsQuery, 4 | StoreListProductReviewsResponse, 5 | StoreListProductReviewStatsQuery, 6 | StoreListProductReviewStatsResponse, 7 | StoreUpsertProductReviewsDTO, 8 | StoreUpsertProductReviewsResponse 9 | } from '../../types'; 10 | 11 | export class StoreProductReviewsResource { 12 | constructor(private client: Client) {} 13 | 14 | async upsert(data: StoreUpsertProductReviewsDTO, headers?: ClientHeaders) { 15 | return this.client.fetch(`/store/product-reviews`, { 16 | method: 'POST', 17 | body: data, 18 | headers, 19 | }); 20 | } 21 | 22 | async list(query: StoreListProductReviewsQuery, headers?: ClientHeaders) { 23 | return this.client.fetch(`/store/product-reviews`, { 24 | method: 'GET', 25 | query, 26 | headers, 27 | }); 28 | } 29 | 30 | async listStats(query: StoreListProductReviewStatsQuery, headers?: ClientHeaders) { 31 | return this.client.fetch(`/store/product-review-stats`, { 32 | method: 'GET', 33 | query, 34 | headers, 35 | }); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/plugins-sdk/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './product-reviews' 2 | export * from './product-review-stats' 3 | -------------------------------------------------------------------------------- /packages/plugins-sdk/src/types/product-review-stats.ts: -------------------------------------------------------------------------------- 1 | export type StoreProductReviewStats = { 2 | id: string; 3 | product_id: string; 4 | average_rating: number | null; 5 | review_count: number; 6 | rating_count_1: number; 7 | rating_count_2: number; 8 | rating_count_3: number; 9 | rating_count_4: number; 10 | rating_count_5: number; 11 | raw_average_rating: Record | null; 12 | created_at: Date; 13 | updated_at: Date; 14 | deleted_at: Date | null; 15 | } 16 | 17 | export type StoreListProductReviewStatsResponse = { 18 | product_review_stats: StoreProductReviewStats[]; 19 | count: number; 20 | offset: number; 21 | limit: number; 22 | } 23 | 24 | export type StoreListProductReviewStatsQuery = { 25 | offset: number; 26 | limit: number; 27 | fields?: string | undefined; 28 | order?: string | undefined; 29 | id?: string | string[] | undefined; 30 | product_id?: string | string[] | undefined; 31 | average_rating?: number | number[] | undefined; 32 | created_at?: any; 33 | updated_at?: any; 34 | } 35 | -------------------------------------------------------------------------------- /packages/plugins-sdk/src/types/product-reviews.ts: -------------------------------------------------------------------------------- 1 | export type ProductReviewStatus = 'pending' | 'approved' | 'flagged'; 2 | 3 | export type AdminCreateProductReviewResponseDTO = { 4 | content: string; 5 | }; 6 | 7 | export type AdminUpdateProductReviewResponseDTO = { 8 | content: string; 9 | }; 10 | 11 | export type AdminListProductReviewsQuery = { 12 | q?: string; 13 | id?: string | string[]; 14 | product_id?: string | string[]; 15 | order_id?: string | string[]; 16 | order_item_id?: string | string[]; 17 | rating?: number | number[]; 18 | limit?: number; 19 | offset?: number; 20 | }; 21 | 22 | export type AdminListProductReviewsResponse = { 23 | product_reviews: AdminProductReview[]; 24 | count: number; 25 | offset: number; 26 | limit: number; 27 | }; 28 | 29 | export type AdminProductReview = { 30 | id: string; 31 | status: ProductReviewStatus; 32 | content: string; 33 | rating: number; 34 | name: string; 35 | email: string; 36 | order_id?: string; 37 | product_id?: string; 38 | order_item_id?: string; 39 | images: { 40 | url: string; 41 | }[]; 42 | created_at: string; 43 | updated_at: string; 44 | product: { 45 | id: string; 46 | thumbnail?: string; 47 | title: string; 48 | }; 49 | order: { 50 | id: string; 51 | display_id: string; 52 | }; 53 | response?: AdminProductReviewResponse; 54 | }; 55 | 56 | export type AdminProductReviewResponse = { 57 | content: string; 58 | product_review_id: string; 59 | created_at: string; 60 | updated_at: string; 61 | }; 62 | 63 | // Storefront 64 | 65 | export type StoreListProductReviewsQuery = { 66 | offset: number; 67 | limit: number; 68 | fields?: string | undefined; 69 | order?: string | undefined; 70 | id?: string | string[] | undefined; 71 | status?: ProductReviewStatus | ProductReviewStatus[] | undefined; 72 | product_id?: string | string[] | undefined; 73 | order_id?: string | string[] | undefined; 74 | rating?: number | number[] | undefined; 75 | created_at?: any; 76 | updated_at?: any; 77 | } 78 | 79 | export type StoreListProductReviewsResponse = { 80 | product_reviews: StoreProductReview[]; 81 | count: number; 82 | offset: number; 83 | limit: number; 84 | }; 85 | 86 | 87 | export type StoreProductReviewResponse = { 88 | id: string; 89 | content: string; 90 | created_at: string; 91 | updated_at: string; 92 | deleted_at: string | null; 93 | product_review_id: string; 94 | } 95 | 96 | export type StoreProductReview = { 97 | id: string; 98 | content: string; 99 | rating: number; 100 | name: string; 101 | email: string; 102 | product_id?: string; 103 | order_item_id?: string; 104 | images: { 105 | url: string; 106 | }[]; 107 | created_at: string; 108 | updated_at: string; 109 | response?: StoreProductReviewResponse; 110 | }; 111 | 112 | 113 | export type StoreUpsertProductReviewsDTO = { 114 | reviews: { 115 | order_id: string; 116 | order_line_item_id: string; 117 | rating: number; 118 | content: string; 119 | images: {url:string}[]; 120 | }[]; 121 | }; 122 | 123 | export type StoreUpsertProductReviewsResponse = { 124 | product_reviews: StoreProductReview[]; 125 | }; 126 | 127 | 128 | export type StoreUploadProductReviewImagesResponse = { 129 | uploads: [{ 130 | url: string; 131 | key: string; 132 | }] | { 133 | url: string; 134 | key: string; 135 | } | undefined 136 | } -------------------------------------------------------------------------------- /packages/plugins-sdk/tsconfig.esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 4 | "target": "ES2022", 5 | "outDir": "./dist/esm", 6 | "esModuleInterop": true, 7 | "declarationMap": true, 8 | "declaration": true, 9 | "module": "ES2022", 10 | "moduleResolution": "Bundler", 11 | "emitDecoratorMetadata": true, 12 | "experimentalDecorators": true, 13 | "sourceMap": true, 14 | "noImplicitReturns": true, 15 | "strictNullChecks": true, 16 | "strictFunctionTypes": true, 17 | "noImplicitThis": true, 18 | "allowJs": true, 19 | "skipLibCheck": true, 20 | "forceConsistentCasingInFileNames": true 21 | }, 22 | "include": ["./src/**/*"], 23 | "exclude": ["./dist/**/*", "./src/**/__tests__", "./src/**/__mocks__", "node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /packages/plugins-sdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2022"], 4 | "target": "ES2022", 5 | "outDir": "./dist/cjs", 6 | "esModuleInterop": true, 7 | "declaration": true, 8 | "declarationMap": true, 9 | "noUnusedLocals": true, 10 | "module": "NodeNext", 11 | "moduleResolution": "NodeNext", 12 | "emitDecoratorMetadata": true, 13 | "experimentalDecorators": true, 14 | "sourceMap": true, 15 | "noImplicitReturns": true, 16 | "resolveJsonModule": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "strict": true, 19 | "allowJs": true, 20 | "skipLibCheck": true, 21 | "incremental": true, 22 | "isolatedModules": true 23 | }, 24 | "include": ["./src/**/*"], 25 | "exclude": ["./dist", "node_modules", "./src/**/__tests__", "./src/**/__mocks__"] 26 | } 27 | -------------------------------------------------------------------------------- /packages/plugins-sdk/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['cjs'], 6 | dts: true, 7 | splitting: false, 8 | sourcemap: true, 9 | clean: true, 10 | treeshake: true, 11 | external: ['@medusajs/js-sdk'] 12 | }) 13 | -------------------------------------------------------------------------------- /plugins/product-reviews/.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 | medusa-db.sql 16 | build 17 | .cache 18 | 19 | .yarn/* 20 | !.yarn/patches 21 | !.yarn/plugins 22 | !.yarn/releases 23 | !.yarn/sdks 24 | !.yarn/versions 25 | 26 | .medusa -------------------------------------------------------------------------------- /plugins/product-reviews/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lambdacurry/medusa-product-reviews", 3 | "version": "1.2.0", 4 | "description": "Product Reviews Plugin for Medusa", 5 | "author": "Lambda Curry (https://lambdacurry.dev)", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/lambda-curry/medusa-plugins" 10 | }, 11 | "homepage": "https://github.com/lambda-curry/medusa-plugins/tree/main/plugins/product-reviews", 12 | "files": [ 13 | ".medusa/server" 14 | ], 15 | "exports": { 16 | "./package.json": "./package.json", 17 | "./workflows": "./.medusa/server/src/workflows/index.js", 18 | "./.medusa/server/src/modules/*": "./.medusa/server/src/modules/*/index.js", 19 | "./providers/*": "./.medusa/server/src/providers/*/index.js", 20 | "./*": "./.medusa/server/src/*.js", 21 | "./admin": { 22 | "import": "./.medusa/server/src/admin/index.mjs", 23 | "require": "./.medusa/server/src/admin/index.js", 24 | "default": "./.medusa/server/src/admin/index.js" 25 | } 26 | }, 27 | "keywords": [ 28 | "medusa", 29 | "plugin", 30 | "product-reviews", 31 | "medusa-plugin-integration", 32 | "medusa-plugin-other", 33 | "medusa-v2", 34 | "medusa-plugin-product-reviews", 35 | "medusa-plugin", 36 | "lambdacurry" 37 | ], 38 | "scripts": { 39 | "build": "medusa plugin:build", 40 | "dev": "medusa plugin:develop", 41 | "dev:publish": "medusa plugin:publish", 42 | "prepublishOnly": "medusa plugin:build", 43 | "db:generate": "DB_USERNAME=${DB_USERNAME:-postgres} DB_PASSWORD=${DB_PASSWORD:-postgres} medusa plugin:db:generate", 44 | "typecheck": "tsc --noEmit" 45 | }, 46 | "devDependencies": { 47 | "@medusajs/admin-sdk": "2.7.0", 48 | "@medusajs/cli": "2.7.0", 49 | "@medusajs/framework": "2.7.0", 50 | "@medusajs/icons": "2.7.0", 51 | "@medusajs/medusa": "2.7.0", 52 | "@medusajs/test-utils": "2.7.0", 53 | "@medusajs/ui": "^4.0.6", 54 | "@mikro-orm/cli": "6.4.3", 55 | "@mikro-orm/core": "6.4.3", 56 | "@mikro-orm/knex": "6.4.3", 57 | "@mikro-orm/migrations": "6.4.3", 58 | "@mikro-orm/postgresql": "6.4.3", 59 | "@swc/core": "1.5.7", 60 | "@types/express": "4.17.13", 61 | "@types/luxon": "^3", 62 | "@types/multer": "^1.4.12", 63 | "@types/node": "^20.0.0", 64 | "@types/react": "^18.3.2", 65 | "@types/react-dom": "^18.2.25", 66 | "awilix": "^8.0.1", 67 | "pg": "^8.13.0", 68 | "prop-types": "^15.8.1", 69 | "react": "^18.2.0", 70 | "react-dom": "^18.2.0", 71 | "react-hook-form": "^7.54.2", 72 | "ts-node": "^10.9.2", 73 | "typescript": "^5.6.2", 74 | "vite": "^5.2.11", 75 | "yalc": "^1.0.0-pre.53" 76 | }, 77 | "peerDependencies": { 78 | "@medusajs/admin-sdk": "2.7.0", 79 | "@medusajs/cli": "2.7.0", 80 | "@medusajs/framework": "2.7.0", 81 | "@medusajs/icons": "2.7.0", 82 | "@medusajs/medusa": "2.7.0", 83 | "@medusajs/test-utils": "2.7.0", 84 | "@medusajs/ui": "4.0.3", 85 | "@mikro-orm/cli": "6.4.3", 86 | "@mikro-orm/core": "6.4.3", 87 | "@mikro-orm/knex": "6.4.3", 88 | "@mikro-orm/migrations": "6.4.3", 89 | "@mikro-orm/postgresql": "6.4.3", 90 | "awilix": "^8.0.1", 91 | "pg": "^8.13.0" 92 | }, 93 | "engines": { 94 | "node": ">=20" 95 | }, 96 | "installConfig": { 97 | "hoistingLimits": "workspaces" 98 | }, 99 | "dependencies": { 100 | "@hookform/resolvers": "3.4.2", 101 | "@lambdacurry/medusa-plugins-sdk": "0.0.5", 102 | "@medusajs/js-sdk": "^2.7.0", 103 | "@medusajs/workflows-sdk": "^2.7.0", 104 | "luxon": "^3.5.0", 105 | "zod": "3.22.4" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /plugins/product-reviews/src/admin/README.md: -------------------------------------------------------------------------------- 1 | # Admin Customizations 2 | 3 | You can extend the Medusa Admin to add widgets and new pages. Your customizations interact with API routes to provide merchants with custom functionalities. 4 | 5 | ## Example: Create a Widget 6 | 7 | A widget is a React component that can be injected into an existing page in the admin dashboard. 8 | 9 | For example, create the file `src/admin/widgets/product-widget.tsx` with the following content: 10 | 11 | ```tsx title="src/admin/widgets/product-widget.tsx" 12 | import { defineWidgetConfig } from "@medusajs/admin-sdk" 13 | 14 | // The widget 15 | const ProductWidget = () => { 16 | return ( 17 |
18 |

Product Widget

19 |
20 | ) 21 | } 22 | 23 | // The widget's configurations 24 | export const config = defineWidgetConfig({ 25 | zone: "product.details.after", 26 | }) 27 | 28 | export default ProductWidget 29 | ``` 30 | 31 | This inserts a widget with the text “Product Widget” at the end of a product’s details page. -------------------------------------------------------------------------------- /plugins/product-reviews/src/admin/components/atoms/action-menu.tsx: -------------------------------------------------------------------------------- 1 | import { EllipsisHorizontal } from '@medusajs/icons'; 2 | import { DropdownMenu, IconButton, clx } from '@medusajs/ui'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | export type Action = { 6 | icon: React.ReactNode; 7 | label: string; 8 | disabled?: boolean; 9 | } & ( 10 | | { 11 | to: string; 12 | onClick?: never; 13 | } 14 | | { 15 | onClick: () => void; 16 | to?: never; 17 | } 18 | ); 19 | 20 | export type ActionGroup = { 21 | actions: Action[]; 22 | }; 23 | 24 | export type ActionMenuProps = { 25 | groups: ActionGroup[]; 26 | }; 27 | 28 | export const ActionMenu = ({ groups }: ActionMenuProps) => { 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {groups.map((group, index) => { 38 | if (!group.actions.length) { 39 | return null; 40 | } 41 | 42 | const isLast = index === groups.length - 1; 43 | 44 | return ( 45 | 46 | {group.actions.map((action, index) => { 47 | if (action.onClick) { 48 | return ( 49 | { 53 | e.stopPropagation(); 54 | action.onClick(); 55 | }} 56 | className={clx('[&_svg]:text-ui-fg-subtle flex items-center gap-x-2', { 57 | '[&_svg]:text-ui-fg-disabled': action.disabled, 58 | })} 59 | > 60 | {action.icon} 61 | {action.label} 62 | 63 | ); 64 | } 65 | 66 | return ( 67 |
68 | 75 | e.stopPropagation()}> 76 | {action.icon} 77 | {action.label} 78 | 79 | 80 |
81 | ); 82 | })} 83 | {!isLast && } 84 |
85 | ); 86 | })} 87 |
88 |
89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /plugins/product-reviews/src/admin/components/atoms/container.tsx: -------------------------------------------------------------------------------- 1 | import { Container as UiContainer, clx } from '@medusajs/ui'; 2 | 3 | type ContainerProps = React.ComponentProps; 4 | 5 | export const Container = (props: ContainerProps) => { 6 | return ; 7 | }; 8 | -------------------------------------------------------------------------------- /plugins/product-reviews/src/admin/components/atoms/header.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Heading, Text } from '@medusajs/ui'; 2 | import React from 'react'; 3 | import { Link, type LinkProps } from 'react-router-dom'; 4 | import { ActionMenu, type ActionMenuProps } from './action-menu'; 5 | 6 | export type HeadingProps = { 7 | title: string; 8 | subtitle?: string; 9 | actions?: ( 10 | | { 11 | type: 'button'; 12 | props: React.ComponentProps; 13 | link?: LinkProps; 14 | } 15 | | { 16 | type: 'action-menu'; 17 | props: ActionMenuProps; 18 | } 19 | | { 20 | type: 'custom'; 21 | children: React.ReactNode; 22 | } 23 | )[]; 24 | }; 25 | 26 | export const Header = ({ title, subtitle, actions = [] }: HeadingProps) => { 27 | return ( 28 |
29 |
30 | {title} 31 | {subtitle && ( 32 | 33 | {subtitle} 34 | 35 | )} 36 |
37 | {actions.length > 0 && ( 38 |
39 | {actions.map((action, index) => ( 40 | 41 | {action.type === 'button' && ( 42 | 48 | )} 49 | {action.type === 'action-menu' && } 50 | {action.type === 'custom' && action.children} 51 | 52 | ))} 53 |
54 | )} 55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /plugins/product-reviews/src/admin/components/atoms/review-stars.tsx: -------------------------------------------------------------------------------- 1 | export const ReviewStars = ({ rating }: { rating: number }) => { 2 | return ( 3 |
4 | {[...Array(5)].map((_, index) => ( 5 | 12 | 17 | 18 | ))} 19 |
20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /plugins/product-reviews/src/admin/components/atoms/section-row.tsx: -------------------------------------------------------------------------------- 1 | import { Text, clx } from '@medusajs/ui'; 2 | 3 | export type SectionRowProps = { 4 | title: string; 5 | value?: React.ReactNode | string | null; 6 | actions?: React.ReactNode; 7 | }; 8 | 9 | export const SectionRow = ({ title, value, actions }: SectionRowProps) => { 10 | const isValueString = typeof value === 'string' || !value; 11 | 12 | return ( 13 |
18 | 19 | {title} 20 | 21 | 22 | {isValueString ? ( 23 | 24 | {value ?? '-'} 25 | 26 | ) : ( 27 |
{value}
28 | )} 29 | 30 | {actions &&
{actions}
} 31 |
32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /plugins/product-reviews/src/admin/components/hooks/product-review.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AdminCreateProductReviewResponseDTO, 3 | AdminListProductReviewsQuery, 4 | AdminListProductReviewsResponse, 5 | AdminUpdateProductReviewResponseDTO, 6 | } from '../../sdk/types'; 7 | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 8 | import { sdk } from '../../sdk'; 9 | 10 | const QUERY_KEY = ['product-reviews']; 11 | 12 | export const useAdminListProductReviews = (query: AdminListProductReviewsQuery) => { 13 | return useQuery({ 14 | queryKey: [...QUERY_KEY, query], 15 | placeholderData: (previousData) => previousData, 16 | queryFn: async () => { 17 | return sdk.admin.productReviews.list(query); 18 | }, 19 | }); 20 | }; 21 | 22 | export const useAdminCreateProductReviewResponseMutation = (reviewId: string) => { 23 | const queryClient = useQueryClient(); 24 | return useMutation({ 25 | mutationFn: async (body: AdminCreateProductReviewResponseDTO) => { 26 | return await sdk.admin.productReviews.createResponse(reviewId, body); 27 | }, 28 | onSuccess: () => { 29 | queryClient.invalidateQueries({ queryKey: QUERY_KEY }); 30 | }, 31 | }); 32 | }; 33 | 34 | export const useAdminUpdateProductReviewResponseMutation = (reviewId: string) => { 35 | const queryClient = useQueryClient(); 36 | return useMutation({ 37 | mutationFn: async (body: AdminUpdateProductReviewResponseDTO) => { 38 | return await sdk.admin.productReviews.updateResponse(reviewId, body); 39 | }, 40 | onSuccess: () => { 41 | queryClient.invalidateQueries({ queryKey: QUERY_KEY }); 42 | }, 43 | }); 44 | }; 45 | 46 | export const useAdminUpdateProductReviewStatusMutation = () => { 47 | const queryClient = useQueryClient(); 48 | return useMutation({ 49 | mutationFn: async ({ 50 | reviewId, 51 | status 52 | }: { 53 | reviewId: string; 54 | status: 'pending' | 'approved' | 'flagged'; 55 | }) => { 56 | return await sdk.admin.productReviews.updateStatus(reviewId, status); 57 | }, 58 | onSuccess: () => { 59 | queryClient.invalidateQueries({ queryKey: QUERY_KEY }); 60 | }, 61 | }); 62 | }; 63 | -------------------------------------------------------------------------------- /plugins/product-reviews/src/admin/components/molecules/ProductReviewResponseDrawer.tsx: -------------------------------------------------------------------------------- 1 | import type { AdminProductReview } from '@lambdacurry/medusa-plugins-sdk'; 2 | import { Button, Label, Text, Textarea } from '@medusajs/ui'; 3 | import { useForm } from 'react-hook-form'; 4 | // import { zodResolver } from '@hookform/resolvers'; 5 | import * as z from 'zod'; 6 | 7 | import { Drawer } from '@medusajs/ui'; 8 | import { 9 | useAdminCreateProductReviewResponseMutation, 10 | useAdminUpdateProductReviewResponseMutation, 11 | } from '../hooks/product-review'; 12 | 13 | const schema = z.object({ 14 | content: z 15 | .string() 16 | .min(1) 17 | .refine((val) => val.trim().split(/\s+/).length >= 1, { 18 | message: 'Response must contain at least one word', 19 | }), 20 | }); 21 | 22 | type FormValues = z.infer; 23 | 24 | export const ProductReviewResponseDrawer = ({ 25 | review, 26 | open, 27 | setOpen, 28 | }: { review: AdminProductReview | null; open: boolean; setOpen: (open: boolean) => void }) => { 29 | const title = review?.response ? 'Edit Response' : 'Add Response'; 30 | 31 | const { mutate: createResponse } = useAdminCreateProductReviewResponseMutation(review?.id ?? ''); 32 | const { mutate: updateResponse } = useAdminUpdateProductReviewResponseMutation(review?.id ?? ''); 33 | 34 | const form = useForm({ 35 | defaultValues: { 36 | content: review?.response?.content ?? '', 37 | }, 38 | // resolver: zodResolver(schema), 39 | }); 40 | 41 | if (!review) return null; 42 | 43 | const onSubmit = async (data: FormValues) => { 44 | if (review.response) { 45 | await updateResponse(data); 46 | } else { 47 | await createResponse(data); 48 | } 49 | setOpen(false); 50 | }; 51 | 52 | return ( 53 | 54 | 55 | 56 | {title} 57 | 58 | 59 | 60 |
61 |
62 | 63 |