├── .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 ├── braintree-payment │ ├── README.md │ ├── jest.config.cjs │ ├── package.json │ ├── src │ │ ├── admin │ │ │ ├── README.md │ │ │ ├── tsconfig.json │ │ │ └── vite-env.d.ts │ │ ├── api │ │ │ ├── README.md │ │ │ ├── admin │ │ │ │ └── plugin │ │ │ │ │ └── README.md │ │ │ └── store │ │ │ │ └── README.md │ │ ├── index.ts │ │ ├── jobs │ │ │ └── README.md │ │ ├── links │ │ │ └── README.md │ │ ├── modules │ │ │ └── README.md │ │ ├── providers │ │ │ └── payment-braintree │ │ │ │ ├── README.md │ │ │ │ └── src │ │ │ │ ├── core │ │ │ │ ├── __tests__ │ │ │ │ │ ├── braintree-base.spec.ts │ │ │ │ │ └── braintree-import.spec.ts │ │ │ │ ├── braintree-base.ts │ │ │ │ └── braintree-import.ts │ │ │ │ ├── index.ts │ │ │ │ ├── services │ │ │ │ ├── braintree-import.ts │ │ │ │ ├── braintree-provider.ts │ │ │ │ └── index.ts │ │ │ │ ├── types │ │ │ │ └── index.ts │ │ │ │ └── workflows │ │ │ │ └── README.md │ │ ├── subscribers │ │ │ └── README.md │ │ ├── types │ │ │ └── index.ts │ │ ├── utils │ │ │ └── format-amount.ts │ │ └── workflows │ │ │ └── README.md │ ├── tsconfig.json │ └── tsconfig.spec.json ├── 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 | *.tgz 10 | coverage 11 | launch.json 12 | !src/** 13 | .medusa/ 14 | 15 | ./tsconfig.tsbuildinfo 16 | medusa-db.sql 17 | build 18 | .cache 19 | 20 | .yarn/* 21 | !.yarn/patches 22 | !.yarn/plugins 23 | !.yarn/releases 24 | !.yarn/sdks 25 | !.yarn/versions 26 | 27 | .medusa 28 | 29 | .turbo 30 | 31 | .yalc 32 | yalc.lock 33 | 34 | plugins/**/node_modules -------------------------------------------------------------------------------- /.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 | "cSpell.words": [ 14 | "Braintree" 15 | ] 16 | } -------------------------------------------------------------------------------- /.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/braintree-payment/jest.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | roots: ['/src'], 6 | testMatch: ['**/__tests__/**/*.spec.ts', '**/*.spec.ts'], 7 | transform: { 8 | '^.+\\.(ts|tsx)$': [ 9 | 'ts-jest', 10 | { tsconfig: '/tsconfig.spec.json', diagnostics: false, isolatedModules: true } 11 | ], 12 | }, 13 | moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], 14 | verbose: false, 15 | collectCoverage: false, 16 | // Ignore compiled medusa build output 17 | testPathIgnorePatterns: ['/node_modules/', '/.medusa/'], 18 | }; 19 | 20 | 21 | -------------------------------------------------------------------------------- /plugins/braintree-payment/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lambdacurry/medusa-payment-braintree", 3 | "version": "0.0.14", 4 | "description": "Braintree plugin for Medusa", 5 | "author": "Lambda Curry (https://lambdacurry.dev)", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/lambda-curry/medusa-plugins.git" 10 | }, 11 | "homepage": "https://github.com/lambda-curry/medusa-plugins/tree/main/plugins/braintree-payment", 12 | "files": [ 13 | ".medusa/server" 14 | ], 15 | "main": "./.medusa/server/src/index.js", 16 | "types": "./.medusa/server/src/index.d.ts", 17 | "exports": { 18 | "./package.json": "./package.json", 19 | "./.medusa/server/src/modules/*": "./.medusa/server/src/modules/*/index.js", 20 | "./providers/*": "./.medusa/server/src/providers/*/src/index.js", 21 | "./types": { 22 | "types": "./.medusa/server/src/types/index.d.ts", 23 | "import": "./.medusa/server/src/types/index.js", 24 | "require": "./.medusa/server/src/types/index.js", 25 | "default": "./.medusa/server/src/types/index.js" 26 | }, 27 | "./*": "./.medusa/server/src/*.js" 28 | }, 29 | "keywords": [ 30 | "medusa", 31 | "plugin", 32 | "braintree", 33 | "medusa-plugin-integration", 34 | "medusa-v2", 35 | "medusa-plugin-braintree", 36 | "medusa-plugin", 37 | "medusa-plugin-payment", 38 | "lambdacurry" 39 | ], 40 | "devDependencies": { 41 | "@medusajs/admin-sdk": "^2.8.2", 42 | "@medusajs/cli": "^2.8.2", 43 | "@medusajs/framework": "^2.8.2", 44 | "@medusajs/icons": "^2.8.2", 45 | "@medusajs/medusa": "^2.8.2", 46 | "@medusajs/test-utils": "^2.8.2", 47 | "@medusajs/ui": "^4.0.3", 48 | "@swc/core": "1.5.7", 49 | "@types/braintree": "^3.3.14", 50 | "@types/jest": "^29.5.12", 51 | "@types/jsonwebtoken": "^9.0.10", 52 | "jest": "^29.7.0", 53 | "jsonwebtoken": "^9.0.2", 54 | "ts-jest": "^29.2.5", 55 | "typescript": "^5.7.2" 56 | }, 57 | "scripts": { 58 | "build": "npx medusa plugin:build", 59 | "test": "jest --config jest.config.cjs --runInBand", 60 | "plugin:dev": "npx medusa plugin:develop", 61 | "prepublishOnly": "npx medusa plugin:build", 62 | "typecheck": "tsc --noEmit" 63 | }, 64 | "peerDependencies": { 65 | "@medusajs/admin-sdk": "^2.8.2", 66 | "@medusajs/cli": "^2.8.2", 67 | "@medusajs/framework": "^2.8.2", 68 | "@medusajs/icons": "^2.8.2", 69 | "@medusajs/medusa": "2.8.2", 70 | "@medusajs/test-utils": "^2.8.2", 71 | "@medusajs/ui": "^4.0.3", 72 | "jsonwebtoken": "^9.0.2" 73 | }, 74 | "engines": { 75 | "node": ">=20" 76 | }, 77 | "dependencies": { 78 | "braintree": "^3.30.0" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /plugins/braintree-payment/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/braintree-payment/src/admin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["."] 24 | } -------------------------------------------------------------------------------- /plugins/braintree-payment/src/admin/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /plugins/braintree-payment/src/api/admin/plugin/README.md: -------------------------------------------------------------------------------- 1 | ### place holder for store routes -------------------------------------------------------------------------------- /plugins/braintree-payment/src/api/store/README.md: -------------------------------------------------------------------------------- 1 | ### place holder for store routes -------------------------------------------------------------------------------- /plugins/braintree-payment/src/index.ts: -------------------------------------------------------------------------------- 1 | // Export types 2 | export * from './providers/payment-braintree/src/types'; 3 | export * from './providers/payment-braintree/src/core/braintree-base'; 4 | 5 | // Export provider 6 | export { default as BraintreePaymentProvider } from './providers/payment-braintree/src'; 7 | -------------------------------------------------------------------------------- /plugins/braintree-payment/src/jobs/README.md: -------------------------------------------------------------------------------- 1 | # Custom scheduled jobs 2 | 3 | A scheduled job is a function executed at a specified interval of time in the background of your Medusa application. 4 | 5 | A scheduled job is created in a TypeScript or JavaScript file under the `src/jobs` directory. 6 | 7 | For example, create the file `src/jobs/hello-world.ts` with the following content: 8 | 9 | ```ts 10 | import { 11 | MedusaContainer 12 | } from "@medusajs/framework/types"; 13 | 14 | export default async function myCustomJob(container: MedusaContainer) { 15 | const productService = container.resolve("product") 16 | 17 | const products = await productService.listAndCountProducts(); 18 | 19 | // Do something with the products 20 | } 21 | 22 | export const config = { 23 | name: "daily-product-report", 24 | schedule: "0 0 * * *", // Every day at midnight 25 | }; 26 | ``` 27 | 28 | A scheduled job file must export: 29 | 30 | - The function to be executed whenever it’s time to run the scheduled job. 31 | - A configuration object defining the job. It has three properties: 32 | - `name`: a unique name for the job. 33 | - `schedule`: a [cron expression](https://crontab.guru/). 34 | - `numberOfExecutions`: an optional integer, specifying how many times the job will execute before being removed 35 | 36 | The `handler` is a function that accepts one parameter, `container`, which is a `MedusaContainer` instance used to resolve services. 37 | -------------------------------------------------------------------------------- /plugins/braintree-payment/src/links/README.md: -------------------------------------------------------------------------------- 1 | # Module Links 2 | 3 | A module link forms an association between two data models of different modules, while maintaining module isolation. 4 | 5 | Learn more about links in [this documentation](https://docs.medusajs.com/learn/fundamentals/module-links) 6 | 7 | For example: 8 | 9 | ```ts 10 | import BlogModule from "../modules/blog" 11 | import ProductModule from "@medusajs/medusa/product" 12 | import { defineLink } from "@medusajs/framework/utils" 13 | 14 | export default defineLink( 15 | ProductModule.linkable.product, 16 | BlogModule.linkable.post 17 | ) 18 | ``` 19 | 20 | This defines a link between the Product Module's `product` data model and the Blog Module (custom module)'s `post` data model. 21 | 22 | Then, in the Medusa application using this plugin, run the following command to sync the links to the database: 23 | 24 | ```bash 25 | npx medusa db:migrate 26 | ``` -------------------------------------------------------------------------------- /plugins/braintree-payment/src/providers/payment-braintree/README.md: -------------------------------------------------------------------------------- 1 | # Braintree Payment Provider for Medusa 2 | 3 | This plugin integrates Braintree as a payment provider for your Medusa store. It allows you to process payments, handle 3D Secure authentication, and manage payment methods seamlessly. 4 | 5 | ## Installation 6 | 7 | Install the plugin in your Medusa project: 8 | 9 | ```bash 10 | npm install medusa-plugin-braintree 11 | ``` 12 | 13 | ## Configuration 14 | 15 | ### Environment Variables 16 | 17 | Set the following environment variables in your `.env` file: 18 | 19 | ```env 20 | BRAINTREE_PUBLIC_KEY= 21 | BRAINTREE_MERCHANT_ID= 22 | BRAINTREE_PRIVATE_KEY= 23 | BRAINTREE_WEBHOOK_SECRET= 24 | BRAINTREE_ENVIRONMENT=one of "sandbox" | "devevlopmnent" | "production" | "qa" 25 | BRAINTREE_ENABLE_3D_SECURE=true 26 | ``` 27 | 28 | ### Medusa Configuration 29 | 30 | Add the following configuration to the `payment` section of your `medusa-config.js` or `config.ts` file: 31 | 32 | ```javascript 33 | { 34 | resolve: 'medusa-plugin-braintree/providers/payment-braintree/src', 35 | id: 'braintree', 36 | options: { 37 | environment: process.env.NODE_ENV !== 'production' ? 'sandbox' : 'production', 38 | merchantId: process.env.BRAINTREE_MERCHANT_ID, 39 | publicKey: process.env.BRAINTREE_PUBLIC_KEY, 40 | privateKey: process.env.BRAINTREE_PRIVATE_KEY, 41 | webhookSecret: process.env.BRAINTREE_WEBHOOK_SECRET, 42 | enable3DSecure: process.env.BRAINTREE_ENABLE_3D_SECURE === 'true', 43 | savePaymentMethod: true, 44 | autoCapture: true, 45 | customFields: ['medusa_payment_session_id', 'cart_id', 'customer_id'], 46 | } 47 | } 48 | ``` 49 | 50 | ### Options 51 | 52 | - **merchantId**: Your Braintree Merchant ID. 53 | - **publicKey**: Your Braintree Public Key. 54 | - **privateKey**: Your Braintree Private Key. 55 | - **webhookSecret**: Secret for validating Braintree webhooks. 56 | - **enable3DSecure**: Enable 3D Secure authentication (`true` or `false`). 57 | - **savePaymentMethod**: Save payment methods for future use (default: `true`). 58 | - **autoCapture**: Automatically capture payments (default: `true`). 59 | - **customFields**: Array of Braintree custom field API names permitted to be forwarded from `data.custom_fields`. If empty or omitted, no user-provided custom fields are sent. 60 | 61 | ## Features 62 | 63 | - Secure payment processing with Braintree. 64 | - Support for 3D Secure authentication. 65 | - Webhook handling for payment updates. 66 | - Save payment methods for future transactions. 67 | 68 | ### Creating Custom Fields in Braintree Dashboard 69 | 70 | - Navigate to: Account Settings → Transactions → Custom Fields. 71 | - Add the fields you plan to send. API names must be lowercase. 72 | - Set fields to "Store and Pass back" if you want them on the transaction record. 73 | 74 | Common examples: 75 | 76 | - `medusa_payment_session_id`: Medusa Session Id 77 | - `cart_id`: Cart Id 78 | - `customer_id`: Customer Id 79 | 80 | Only fields listed in `options.customFields` and supplied in `data.custom_fields` are forwarded. 81 | 82 | ### Supplying Custom Fields and Order ID 83 | 84 | - `data.custom_fields`: object map of API name → value. Values are coerced to strings; only whitelisted keys are sent. 85 | - `data.order_id`: string forwarded as Braintree `orderId` in the sale request. 86 | 87 | Update behavior: `updatePayment` merges `custom_fields` by overwriting existing keys but keeping unspecified ones. 88 | 89 | ## License 90 | 91 | This plugin is licensed under the [MIT License](LICENSE). 92 | 93 | For more information, visit the [Braintree Documentation](https://developer.paypal.com/braintree/docs). 94 | -------------------------------------------------------------------------------- /plugins/braintree-payment/src/providers/payment-braintree/src/index.ts: -------------------------------------------------------------------------------- 1 | import { ModuleProvider, Modules } from '@medusajs/framework/utils'; 2 | import { BraintreeProviderService, BraintreeImportService } from './services'; 3 | 4 | const services = [BraintreeProviderService, BraintreeImportService]; 5 | 6 | export default ModuleProvider(Modules.PAYMENT, { 7 | services, 8 | }); 9 | -------------------------------------------------------------------------------- /plugins/braintree-payment/src/providers/payment-braintree/src/services/braintree-import.ts: -------------------------------------------------------------------------------- 1 | import type { BraintreeOptions } from '../types'; 2 | import { PaymentProviderKeys } from '../types'; 3 | import { BraintreeConstructorArgs } from '../core/braintree-base'; 4 | import BraintreeImport from '../core/braintree-import'; 5 | 6 | class BraintreeImportService extends BraintreeImport { 7 | static identifier = PaymentProviderKeys.IMPORTED; 8 | options: BraintreeOptions; 9 | 10 | constructor(container: BraintreeConstructorArgs, options: BraintreeOptions) { 11 | super(container, options); 12 | this.options = options; 13 | } 14 | } 15 | 16 | export default BraintreeImportService; 17 | -------------------------------------------------------------------------------- /plugins/braintree-payment/src/providers/payment-braintree/src/services/braintree-provider.ts: -------------------------------------------------------------------------------- 1 | import BraintreeBase, { BraintreeConstructorArgs } from '../core/braintree-base'; 2 | import type { BraintreeOptions } from '../types'; 3 | import { PaymentProviderKeys } from '../types'; 4 | 5 | class BraintreeProviderService extends BraintreeBase { 6 | static identifier = PaymentProviderKeys.BRAINTREE; 7 | options: BraintreeOptions; 8 | 9 | constructor(container: BraintreeConstructorArgs, options: BraintreeOptions) { 10 | super(container, options); 11 | this.options = options; 12 | } 13 | } 14 | 15 | export default BraintreeProviderService; 16 | -------------------------------------------------------------------------------- /plugins/braintree-payment/src/providers/payment-braintree/src/services/index.ts: -------------------------------------------------------------------------------- 1 | export { default as BraintreeProviderService } from './braintree-provider'; 2 | export { default as BraintreeImportService } from './braintree-import'; 3 | -------------------------------------------------------------------------------- /plugins/braintree-payment/src/providers/payment-braintree/src/types/index.ts: -------------------------------------------------------------------------------- 1 | import type Braintree from 'braintree'; 2 | 3 | export interface BraintreeOptions extends Braintree.ClientGatewayConfig { 4 | environment: 'production' | 'sandbox' | 'development' | 'qa'; 5 | merchantId: string; 6 | publicKey: string; 7 | privateKey: string; 8 | enable3DSecure: boolean; 9 | savePaymentMethod: boolean; 10 | webhookSecret: string; 11 | autoCapture: boolean; 12 | } 13 | 14 | export const PaymentProviderKeys = { 15 | BRAINTREE: 'braintree', 16 | IMPORTED: 'imported', 17 | }; 18 | 19 | // Flexible map of custom fields returned by Braintree. 20 | // Values are represented as strings by the API. 21 | export type CustomFields = Record; 22 | 23 | export interface DecodedClientToken { 24 | version: number; 25 | authorizationFingerprint: string; 26 | configUrl: string; 27 | graphQL: GraphQl; 28 | clientApiUrl: string; 29 | environment: string; 30 | merchantId: string; 31 | assetsUrl: string; 32 | authUrl: string; 33 | venmo: string; 34 | challenges: string[]; 35 | threeDSecureEnabled: boolean; 36 | analytics: Analytics; 37 | paypalEnabled: boolean; 38 | paypal: Paypal; 39 | } 40 | 41 | export interface GraphQl { 42 | url: string; 43 | date: string; 44 | features: string[]; 45 | } 46 | 47 | export interface Analytics { 48 | url: string; 49 | } 50 | 51 | export interface Paypal { 52 | billingAgreementsEnabled: boolean; 53 | environmentNoNetwork: boolean; 54 | unvettedMerchant: boolean; 55 | allowHttp: boolean; 56 | displayName: string; 57 | clientId: string; 58 | baseUrl: string; 59 | assetsUrl: string; 60 | directBaseUrl: string; 61 | environment: string; 62 | braintreeClientId: string; 63 | merchantAccountId: string; 64 | currencyIsoCode: string; 65 | } 66 | 67 | export interface DecodedClientTokenAuthorization { 68 | exp: number; 69 | jti: string; 70 | sub: string; 71 | iss: string; 72 | merchant: Merchant; 73 | rights: string[]; 74 | scope: string[]; 75 | options: Options; 76 | } 77 | 78 | export interface Merchant { 79 | public_id: string; 80 | verify_card_by_default: boolean; 81 | verify_wallet_by_default: boolean; 82 | } 83 | 84 | export interface Options {} 85 | -------------------------------------------------------------------------------- /plugins/braintree-payment/src/providers/payment-braintree/src/workflows/README.md: -------------------------------------------------------------------------------- 1 | # Custom Workflows 2 | 3 | A workflow is a series of queries and actions that complete a task. 4 | 5 | The workflow is created in a TypeScript or JavaScript file under the `src/workflows` directory. 6 | 7 | For example: 8 | 9 | ```ts 10 | import { 11 | createStep, 12 | createWorkflow, 13 | WorkflowResponse, 14 | StepResponse, 15 | } from "@medusajs/framework/workflows-sdk" 16 | 17 | const step1 = createStep("step-1", async () => { 18 | return new StepResponse(`Hello from step one!`) 19 | }) 20 | 21 | type WorkflowInput = { 22 | name: string 23 | } 24 | 25 | const step2 = createStep( 26 | "step-2", 27 | async ({ name }: WorkflowInput) => { 28 | return new StepResponse(`Hello ${name} from step two!`) 29 | } 30 | ) 31 | 32 | type WorkflowOutput = { 33 | message1: string 34 | message2: string 35 | } 36 | 37 | const helloWorldWorkflow = createWorkflow( 38 | "hello-world", 39 | (input: WorkflowInput) => { 40 | const greeting1 = step1() 41 | const greeting2 = step2(input) 42 | 43 | return new WorkflowResponse({ 44 | message1: greeting1, 45 | message2: greeting2 46 | }) 47 | } 48 | ) 49 | 50 | export default helloWorldWorkflow 51 | ``` 52 | 53 | ## Execute Workflow 54 | 55 | You can execute the workflow from other resources, such as API routes, scheduled jobs, or subscribers. 56 | 57 | For example, to execute the workflow in an API route: 58 | 59 | ```ts 60 | import type { 61 | MedusaRequest, 62 | MedusaResponse, 63 | } from "@medusajs/framework" 64 | import myWorkflow from "../../../workflows/hello-world" 65 | 66 | export async function GET( 67 | req: MedusaRequest, 68 | res: MedusaResponse 69 | ) { 70 | const { result } = await myWorkflow(req.scope) 71 | .run({ 72 | input: { 73 | name: req.query.name as string, 74 | }, 75 | }) 76 | 77 | res.send(result) 78 | } 79 | ``` 80 | -------------------------------------------------------------------------------- /plugins/braintree-payment/src/subscribers/README.md: -------------------------------------------------------------------------------- 1 | # Custom subscribers 2 | 3 | Subscribers handle events emitted in the Medusa application. 4 | 5 | The subscriber is created in a TypeScript or JavaScript file under the `src/subscribers` directory. 6 | 7 | For example, create the file `src/subscribers/product-created.ts` with the following content: 8 | 9 | ```ts 10 | import { 11 | type SubscriberConfig, 12 | } from "@medusajs/framework" 13 | 14 | // subscriber function 15 | export default async function productCreateHandler() { 16 | console.log("A product was created") 17 | } 18 | 19 | // subscriber config 20 | export const config: SubscriberConfig = { 21 | event: "product.created", 22 | } 23 | ``` 24 | 25 | A subscriber file must export: 26 | 27 | - The subscriber function that is an asynchronous function executed whenever the associated event is triggered. 28 | - A configuration object defining the event this subscriber is listening to. 29 | 30 | ## Subscriber Parameters 31 | 32 | A subscriber receives an object having the following properties: 33 | 34 | - `event`: An object holding the event's details. It has a `data` property, which is the event's data payload. 35 | - `container`: The Medusa container. Use it to resolve modules' main services and other registered resources. 36 | 37 | ```ts 38 | import type { 39 | SubscriberArgs, 40 | SubscriberConfig, 41 | } from "@medusajs/framework" 42 | 43 | export default async function productCreateHandler({ 44 | event: { data }, 45 | container, 46 | }: SubscriberArgs<{ id: string }>) { 47 | const productId = data.id 48 | 49 | const productModuleService = container.resolve("product") 50 | 51 | const product = await productModuleService.retrieveProduct(productId) 52 | 53 | console.log(`The product ${product.title} was created`) 54 | } 55 | 56 | export const config: SubscriberConfig = { 57 | event: "product.created", 58 | } 59 | ``` -------------------------------------------------------------------------------- /plugins/braintree-payment/src/types/index.ts: -------------------------------------------------------------------------------- 1 | // Re-export all types from the provider 2 | export * from '../providers/payment-braintree/src/types'; 3 | export * from '../providers/payment-braintree/src/core/braintree-base'; 4 | -------------------------------------------------------------------------------- /plugins/braintree-payment/src/utils/format-amount.ts: -------------------------------------------------------------------------------- 1 | import { MedusaError } from '@medusajs/framework/utils'; 2 | 3 | /** 4 | * Formats a number or string to a two-decimal string representation. 5 | * Validates the input is parseable to a number and throws MedusaError on NaN. 6 | * 7 | * @param amount - The amount to format (number or string) 8 | * @returns A string representation with exactly 2 decimal places 9 | * @throws MedusaError if the amount is not a valid number 10 | */ 11 | export function formatToTwoDecimalString(amount: number | string): string { 12 | if (typeof amount !== 'string') { 13 | amount = amount.toString(); 14 | } 15 | 16 | const num = Number.parseFloat(amount); 17 | 18 | if (Number.isNaN(num)) { 19 | throw new MedusaError(MedusaError.Types.INVALID_ARGUMENT, 'Invalid amount'); 20 | } 21 | 22 | return num.toFixed(2); 23 | } 24 | -------------------------------------------------------------------------------- /plugins/braintree-payment/src/workflows/README.md: -------------------------------------------------------------------------------- 1 | # Custom Workflows 2 | 3 | A workflow is a series of queries and actions that complete a task. 4 | 5 | The workflow is created in a TypeScript or JavaScript file under the `src/workflows` directory. 6 | 7 | For example: 8 | 9 | ```ts 10 | import { 11 | createStep, 12 | createWorkflow, 13 | WorkflowResponse, 14 | StepResponse, 15 | } from "@medusajs/framework/workflows-sdk" 16 | 17 | const step1 = createStep("step-1", async () => { 18 | return new StepResponse(`Hello from step one!`) 19 | }) 20 | 21 | type WorkflowInput = { 22 | name: string 23 | } 24 | 25 | const step2 = createStep( 26 | "step-2", 27 | async ({ name }: WorkflowInput) => { 28 | return new StepResponse(`Hello ${name} from step two!`) 29 | } 30 | ) 31 | 32 | type WorkflowOutput = { 33 | message1: string 34 | message2: string 35 | } 36 | 37 | const helloWorldWorkflow = createWorkflow( 38 | "hello-world", 39 | (input: WorkflowInput) => { 40 | const greeting1 = step1() 41 | const greeting2 = step2(input) 42 | 43 | return new WorkflowResponse({ 44 | message1: greeting1, 45 | message2: greeting2 46 | }) 47 | } 48 | ) 49 | 50 | export default helloWorldWorkflow 51 | ``` 52 | 53 | ## Execute Workflow 54 | 55 | You can execute the workflow from other resources, such as API routes, scheduled jobs, or subscribers. 56 | 57 | For example, to execute the workflow in an API route: 58 | 59 | ```ts 60 | import type { 61 | MedusaRequest, 62 | MedusaResponse, 63 | } from "@medusajs/framework" 64 | import myWorkflow from "../../../workflows/hello-world" 65 | 66 | export async function GET( 67 | req: MedusaRequest, 68 | res: MedusaResponse 69 | ) { 70 | const { result } = await myWorkflow(req.scope) 71 | .run({ 72 | input: { 73 | name: req.query.name as string, 74 | }, 75 | }) 76 | 77 | res.send(result) 78 | } 79 | ``` 80 | -------------------------------------------------------------------------------- /plugins/braintree-payment/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "esModuleInterop": true, 5 | "module": "Node16", 6 | "moduleResolution": "Node16", 7 | "emitDecoratorMetadata": true, 8 | "experimentalDecorators": true, 9 | "skipLibCheck": true, 10 | "skipDefaultLibCheck": true, 11 | "declaration": true, 12 | "sourceMap": false, 13 | "inlineSourceMap": true, 14 | "outDir": "./.medusa/server", 15 | "rootDir": "./", 16 | "jsx": "react-jsx", 17 | "forceConsistentCasingInFileNames": true, 18 | "resolveJsonModule": true, 19 | "checkJs": false, 20 | "strictNullChecks": true 21 | }, 22 | "ts-node": { 23 | "swc": true 24 | }, 25 | "include": [ 26 | "**/*", 27 | ".medusa/types/*" 28 | ], 29 | "exclude": [ 30 | "**/*.spec.ts", 31 | "**/*.spec.tsx", 32 | "node_modules", 33 | ".medusa/server", 34 | ".medusa/admin", 35 | "src/admin", 36 | ".cache", 37 | "__tests__", 38 | "*.spec.*" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /plugins/braintree-payment/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "types": [ 6 | "jest", 7 | "node" 8 | ], 9 | "jsx": "react-jsx", 10 | "allowJs": false, 11 | "module": "Node16", 12 | "moduleResolution": "Node16", 13 | "esModuleInterop": true, 14 | "skipLibCheck": true 15 | }, 16 | "include": [ 17 | "src/**/*.ts", 18 | "src/**/*.tsx" 19 | ], 20 | "exclude": [ 21 | "node_modules", 22 | ".medusa/server", 23 | ".medusa/admin" 24 | ] 25 | } -------------------------------------------------------------------------------- /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/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 |