├── .gitignore ├── README.md ├── backend ├── .env.template ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode │ └── settings.json ├── .yarnrc.yml ├── README.md ├── medusa-config.js ├── package.json ├── pnpm-lock.yaml ├── src │ ├── admin │ │ ├── README.md │ │ └── tsconfig.json │ ├── api │ │ ├── README.md │ │ ├── admin │ │ │ └── custom │ │ │ │ └── route.ts │ │ ├── key-exchange │ │ │ └── route.ts │ │ └── store │ │ │ └── custom │ │ │ └── route.ts │ ├── jobs │ │ └── README.md │ ├── lib │ │ └── constants.ts │ ├── modules │ │ ├── README.md │ │ ├── email-notifications │ │ │ ├── README.md │ │ │ ├── index.ts │ │ │ ├── services │ │ │ │ └── resend.ts │ │ │ └── templates │ │ │ │ ├── base.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── invite-user.tsx │ │ │ │ └── order-placed.tsx │ │ └── minio-file │ │ │ ├── README.md │ │ │ ├── index.ts │ │ │ └── service.ts │ ├── scripts │ │ ├── README.md │ │ ├── postBuild.js │ │ └── seed.ts │ ├── subscribers │ │ ├── README.md │ │ ├── invite-created.ts │ │ └── order-placed.ts │ ├── utils │ │ └── assert-value.ts │ └── workflows │ │ └── README.md └── tsconfig.json └── storefront ├── .env.local.template ├── .eslintrc.js ├── .github ├── scripts │ └── medusa-config.js └── workflows │ └── test-e2e.yaml ├── .gitignore ├── .prettierrc ├── .yarnrc.yml ├── LICENSE ├── README.md ├── check-env-variables.js ├── e2e ├── .env.example ├── README.md ├── data │ ├── reset.ts │ └── seed.ts ├── fixtures │ ├── account │ │ ├── account-page.ts │ │ ├── addresses-page.ts │ │ ├── index.ts │ │ ├── login-page.ts │ │ ├── modals │ │ │ └── address-modal.ts │ │ ├── order-page.ts │ │ ├── orders-page.ts │ │ ├── overview-page.ts │ │ ├── profile-page.ts │ │ └── register-page.ts │ ├── base │ │ ├── base-modal.ts │ │ ├── base-page.ts │ │ ├── cart-dropdown.ts │ │ ├── nav-menu.ts │ │ └── search-modal.ts │ ├── cart-page.ts │ ├── category-page.ts │ ├── checkout-page.ts │ ├── index.ts │ ├── modals │ │ └── mobile-actions-modal.ts │ ├── order-page.ts │ ├── product-page.ts │ └── store-page.ts ├── index.ts ├── tests │ ├── authenticated │ │ ├── address.spec.ts │ │ ├── orders.spec.ts │ │ └── profile.spec.ts │ ├── global │ │ ├── public-setup.ts │ │ ├── setup.ts │ │ └── teardown.ts │ └── public │ │ ├── cart.spec.ts │ │ ├── checkout.spec.ts │ │ ├── discount.spec.ts │ │ ├── giftcard.spec.ts │ │ ├── login.spec.ts │ │ ├── register.spec.ts │ │ └── search.spec.ts └── utils │ ├── index.ts │ └── locators.ts ├── next-env.d.ts ├── next-sitemap.js ├── next.config.js ├── package.json ├── playwright.config.ts ├── pnpm-lock.yaml ├── postcss.config.js ├── public └── favicon.ico ├── src ├── app │ ├── [countryCode] │ │ ├── (checkout) │ │ │ ├── checkout │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── not-found.tsx │ │ └── (main) │ │ │ ├── account │ │ │ ├── @dashboard │ │ │ │ ├── addresses │ │ │ │ │ └── page.tsx │ │ │ │ ├── loading.tsx │ │ │ │ ├── orders │ │ │ │ │ ├── details │ │ │ │ │ │ └── [id] │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── profile │ │ │ │ │ └── page.tsx │ │ │ ├── @login │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ └── loading.tsx │ │ │ ├── cart │ │ │ ├── loading.tsx │ │ │ ├── not-found.tsx │ │ │ └── page.tsx │ │ │ ├── categories │ │ │ └── [...category] │ │ │ │ └── page.tsx │ │ │ ├── collections │ │ │ └── [handle] │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── not-found.tsx │ │ │ ├── order │ │ │ └── confirmed │ │ │ │ └── [id] │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ ├── products │ │ │ └── [handle] │ │ │ │ └── page.tsx │ │ │ ├── results │ │ │ └── [query] │ │ │ │ └── page.tsx │ │ │ ├── search │ │ │ └── page.tsx │ │ │ └── store │ │ │ └── page.tsx │ ├── api │ │ └── healthcheck │ │ │ └── route.ts │ ├── layout.tsx │ ├── not-found.tsx │ ├── opengraph-image.jpg │ └── twitter-image.jpg ├── lib │ ├── config.ts │ ├── constants.tsx │ ├── context │ │ └── modal-context.tsx │ ├── data │ │ ├── cart.ts │ │ ├── categories.ts │ │ ├── collections.ts │ │ ├── cookies.ts │ │ ├── customer.ts │ │ ├── fulfillment.ts │ │ ├── onboarding.ts │ │ ├── orders.ts │ │ ├── payment.ts │ │ ├── products.ts │ │ └── regions.ts │ ├── hooks │ │ ├── use-in-view.tsx │ │ └── use-toggle-state.tsx │ ├── search-client.ts │ └── util │ │ ├── compare-addresses.ts │ │ ├── env.ts │ │ ├── get-precentage-diff.ts │ │ ├── get-product-price.ts │ │ ├── isEmpty.ts │ │ ├── medusa-error.ts │ │ ├── money.ts │ │ ├── repeat.ts │ │ └── sort-products.ts ├── middleware.ts ├── modules │ ├── account │ │ ├── components │ │ │ ├── account-info │ │ │ │ └── index.tsx │ │ │ ├── account-nav │ │ │ │ └── index.tsx │ │ │ ├── address-book │ │ │ │ └── index.tsx │ │ │ ├── address-card │ │ │ │ ├── add-address.tsx │ │ │ │ └── edit-address-modal.tsx │ │ │ ├── login │ │ │ │ └── index.tsx │ │ │ ├── order-card │ │ │ │ └── index.tsx │ │ │ ├── order-overview │ │ │ │ └── index.tsx │ │ │ ├── overview │ │ │ │ └── index.tsx │ │ │ ├── profile-billing-address │ │ │ │ └── index.tsx │ │ │ ├── profile-email │ │ │ │ └── index.tsx │ │ │ ├── profile-name │ │ │ │ └── index.tsx │ │ │ ├── profile-password │ │ │ │ └── index.tsx │ │ │ ├── profile-phone │ │ │ │ └── index.tsx │ │ │ └── register │ │ │ │ └── index.tsx │ │ └── templates │ │ │ ├── account-layout.tsx │ │ │ └── login-template.tsx │ ├── cart │ │ ├── components │ │ │ ├── cart-item-select │ │ │ │ └── index.tsx │ │ │ ├── empty-cart-message │ │ │ │ └── index.tsx │ │ │ ├── item │ │ │ │ └── index.tsx │ │ │ └── sign-in-prompt │ │ │ │ └── index.tsx │ │ └── templates │ │ │ ├── index.tsx │ │ │ ├── items.tsx │ │ │ ├── preview.tsx │ │ │ └── summary.tsx │ ├── categories │ │ └── templates │ │ │ └── index.tsx │ ├── checkout │ │ ├── components │ │ │ ├── address-select │ │ │ │ └── index.tsx │ │ │ ├── addresses │ │ │ │ └── index.tsx │ │ │ ├── billing_address │ │ │ │ └── index.tsx │ │ │ ├── country-select │ │ │ │ └── index.tsx │ │ │ ├── discount-code │ │ │ │ └── index.tsx │ │ │ ├── error-message │ │ │ │ └── index.tsx │ │ │ ├── payment-button │ │ │ │ └── index.tsx │ │ │ ├── payment-container │ │ │ │ └── index.tsx │ │ │ ├── payment-test │ │ │ │ └── index.tsx │ │ │ ├── payment-wrapper │ │ │ │ ├── index.tsx │ │ │ │ └── stripe-wrapper.tsx │ │ │ ├── payment │ │ │ │ └── index.tsx │ │ │ ├── review │ │ │ │ └── index.tsx │ │ │ ├── shipping-address │ │ │ │ └── index.tsx │ │ │ ├── shipping │ │ │ │ └── index.tsx │ │ │ └── submit-button │ │ │ │ └── index.tsx │ │ └── templates │ │ │ ├── checkout-form │ │ │ └── index.tsx │ │ │ └── checkout-summary │ │ │ └── index.tsx │ ├── collections │ │ └── templates │ │ │ └── index.tsx │ ├── common │ │ ├── components │ │ │ ├── cart-totals │ │ │ │ └── index.tsx │ │ │ ├── checkbox │ │ │ │ └── index.tsx │ │ │ ├── delete-button │ │ │ │ └── index.tsx │ │ │ ├── divider │ │ │ │ └── index.tsx │ │ │ ├── filter-radio-group │ │ │ │ └── index.tsx │ │ │ ├── input │ │ │ │ └── index.tsx │ │ │ ├── interactive-link │ │ │ │ └── index.tsx │ │ │ ├── line-item-options │ │ │ │ └── index.tsx │ │ │ ├── line-item-price │ │ │ │ └── index.tsx │ │ │ ├── line-item-unit-price │ │ │ │ └── index.tsx │ │ │ ├── localized-client-link │ │ │ │ └── index.tsx │ │ │ ├── modal │ │ │ │ └── index.tsx │ │ │ ├── native-select │ │ │ │ └── index.tsx │ │ │ └── radio │ │ │ │ └── index.tsx │ │ └── icons │ │ │ ├── back.tsx │ │ │ ├── bancontact.tsx │ │ │ ├── chevron-down.tsx │ │ │ ├── eye-off.tsx │ │ │ ├── eye.tsx │ │ │ ├── fast-delivery.tsx │ │ │ ├── ideal.tsx │ │ │ ├── map-pin.tsx │ │ │ ├── medusa.tsx │ │ │ ├── nextjs.tsx │ │ │ ├── package.tsx │ │ │ ├── paypal.tsx │ │ │ ├── placeholder-image.tsx │ │ │ ├── refresh.tsx │ │ │ ├── spinner.tsx │ │ │ ├── trash.tsx │ │ │ ├── user.tsx │ │ │ └── x.tsx │ ├── home │ │ └── components │ │ │ ├── featured-products │ │ │ ├── index.tsx │ │ │ └── product-rail │ │ │ │ └── index.tsx │ │ │ └── hero │ │ │ └── index.tsx │ ├── layout │ │ ├── components │ │ │ ├── cart-button │ │ │ │ └── index.tsx │ │ │ ├── cart-dropdown │ │ │ │ └── index.tsx │ │ │ ├── country-select │ │ │ │ └── index.tsx │ │ │ ├── medusa-cta │ │ │ │ └── index.tsx │ │ │ └── side-menu │ │ │ │ └── index.tsx │ │ └── templates │ │ │ ├── footer │ │ │ └── index.tsx │ │ │ ├── index.tsx │ │ │ └── nav │ │ │ └── index.tsx │ ├── order │ │ ├── components │ │ │ ├── help │ │ │ │ └── index.tsx │ │ │ ├── item │ │ │ │ └── index.tsx │ │ │ ├── items │ │ │ │ └── index.tsx │ │ │ ├── onboarding-cta │ │ │ │ └── index.tsx │ │ │ ├── order-details │ │ │ │ └── index.tsx │ │ │ ├── order-summary │ │ │ │ └── index.tsx │ │ │ ├── payment-details │ │ │ │ └── index.tsx │ │ │ └── shipping-details │ │ │ │ └── index.tsx │ │ └── templates │ │ │ ├── order-completed-template.tsx │ │ │ └── order-details-template.tsx │ ├── products │ │ ├── components │ │ │ ├── image-gallery │ │ │ │ └── index.tsx │ │ │ ├── product-actions │ │ │ │ ├── index.tsx │ │ │ │ ├── mobile-actions.tsx │ │ │ │ └── option-select.tsx │ │ │ ├── product-onboarding-cta │ │ │ │ └── index.tsx │ │ │ ├── product-preview │ │ │ │ ├── index.tsx │ │ │ │ └── price.tsx │ │ │ ├── product-price │ │ │ │ └── index.tsx │ │ │ ├── product-tabs │ │ │ │ ├── accordion.tsx │ │ │ │ └── index.tsx │ │ │ ├── related-products │ │ │ │ └── index.tsx │ │ │ └── thumbnail │ │ │ │ └── index.tsx │ │ └── templates │ │ │ ├── index.tsx │ │ │ ├── product-actions-wrapper │ │ │ └── index.tsx │ │ │ └── product-info │ │ │ └── index.tsx │ ├── search │ │ ├── actions.ts │ │ ├── components │ │ │ ├── hit │ │ │ │ └── index.tsx │ │ │ ├── hits │ │ │ │ └── index.tsx │ │ │ ├── search-box-wrapper │ │ │ │ └── index.tsx │ │ │ ├── search-box │ │ │ │ └── index.tsx │ │ │ └── show-all │ │ │ │ └── index.tsx │ │ └── templates │ │ │ ├── search-modal │ │ │ └── index.tsx │ │ │ └── search-results-template │ │ │ └── index.tsx │ ├── skeletons │ │ ├── components │ │ │ ├── skeleton-button │ │ │ │ └── index.tsx │ │ │ ├── skeleton-cart-item │ │ │ │ └── index.tsx │ │ │ ├── skeleton-cart-totals │ │ │ │ └── index.tsx │ │ │ ├── skeleton-code-form │ │ │ │ └── index.tsx │ │ │ ├── skeleton-line-item │ │ │ │ └── index.tsx │ │ │ ├── skeleton-order-confirmed-header │ │ │ │ └── index.tsx │ │ │ ├── skeleton-order-information │ │ │ │ └── index.tsx │ │ │ ├── skeleton-order-items │ │ │ │ └── index.tsx │ │ │ ├── skeleton-order-summary │ │ │ │ └── index.tsx │ │ │ └── skeleton-product-preview │ │ │ │ └── index.tsx │ │ └── templates │ │ │ ├── skeleton-cart-page │ │ │ └── index.tsx │ │ │ ├── skeleton-order-confirmed │ │ │ └── index.tsx │ │ │ ├── skeleton-product-grid │ │ │ └── index.tsx │ │ │ └── skeleton-related-products │ │ │ └── index.tsx │ └── store │ │ ├── components │ │ ├── pagination │ │ │ └── index.tsx │ │ └── refinement-list │ │ │ ├── index.tsx │ │ │ └── sort-products │ │ │ └── index.tsx │ │ └── templates │ │ ├── index.tsx │ │ └── paginated-products.tsx ├── styles │ └── globals.css └── types │ ├── global.ts │ └── icon.ts ├── tailwind.config.js └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | -------------------------------------------------------------------------------- /backend/.env.template: -------------------------------------------------------------------------------- 1 | NODE_ENV=development 2 | # REDIS_URL=redis://localhost:6379 # Optional - falls back to simulated redis 3 | ADMIN_CORS=http://localhost:7000,http://localhost:7001,https://docs.medusajs.com 4 | STORE_CORS=http://localhost:8000,https://docs.medusajs.com 5 | AUTH_CORS=http://localhost:7000,http://localhost:7001,https://docs.medusajs.com 6 | JWT_SECRET=supersecret 7 | COOKIE_SECRET=supersecret 8 | DATABASE_URL=postgres://postgres:postgres@localhost:5432/medusa # Make sure this database exist - or change connection string to an online database 9 | 10 | MEDUSA_ADMIN_EMAIL=admin@yourmail.com 11 | MEDUSA_ADMIN_PASSWORD=supersecret 12 | 13 | STRIPE_API_KEY= 14 | STRIPE_WEBHOOK_SECRET= 15 | 16 | SENDGRID_API_KEY= 17 | SENDGRID_FROM= 18 | 19 | # MinIO Storage Configuration (Optional - falls back to local storage) 20 | # MINIO_ENDPOINT=your-minio-endpoint 21 | # MINIO_ACCESS_KEY=your-access-key 22 | # MINIO_SECRET_KEY=your-secret-key 23 | # MINIO_BUCKET=custom-bucket-name # Optional - defaults to 'medusa-media' 24 | 25 | # Meilisearch Configuration (Optional) 26 | # MEILISEARCH_HOST=your-meilisearch-host # e.g. http://localhost:7700 27 | # MEILISEARCH_MASTER_KEY=your-master-key # Required if MEILISEARCH_ADMIN_KEY is not set 28 | # MEILISEARCH_ADMIN_KEY=your-admin-key # Optional - if not set, will be fetched using master key 29 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | .env 3 | .DS_Store 4 | /uploads 5 | /node_modules 6 | yarn-error.log 7 | .medusa-copy 8 | /.idea 9 | static 10 | coverage 11 | 12 | .medusa 13 | 14 | !src/** 15 | 16 | ./tsconfig.tsbuildinfo 17 | # package-lock.json 18 | yarn.lock 19 | medusa-db.sql 20 | build 21 | .cache 22 | 23 | .yarn/* 24 | !.yarn/patches 25 | !.yarn/plugins 26 | !.yarn/releases 27 | !.yarn/sdks 28 | !.yarn/versions 29 | -------------------------------------------------------------------------------- /backend/.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /backend/.nvmrc: -------------------------------------------------------------------------------- 1 | v22.11.0 2 | -------------------------------------------------------------------------------- /backend/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /backend/.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | ### local setup 2 | Video instructions: https://youtu.be/PPxenu7IjGM 3 | 4 | - `cd /backend` 5 | - `pnpm install` or `npm i` 6 | - Rename `.env.template` -> `.env` 7 | - To connect to your online database from your local machine, copy the `DATABASE_URL` value auto-generated on Railway and add it to your `.env` file. 8 | - If connecting to a new database, for example a local one, run `pnpm ib` or `npm run ib` to seed the database. 9 | - `pnpm dev` or `npm run dev` 10 | 11 | ### requirements 12 | - **postgres database** (Automatic setup when using the Railway template) 13 | - **redis** (Automatic setup when using the Railway template) - fallback to simulated redis. 14 | - **MinIO storage** (Automatic setup when using the Railway template) - fallback to local storage. 15 | - **Meilisearch** (Automatic setup when using the Railway template) 16 | 17 | ### commands 18 | 19 | `cd backend/` 20 | `npm run ib` or `pnpm ib` will initialize the backend by running migrations and seed the database with required system data. 21 | `npm run dev` or `pnpm dev` will start the backend (and admin dashboard frontend on `localhost:9000/app`) in development mode. 22 | `pnpm build && pnpm start` will compile the project and run from compiled source. This can be useful for reproducing issues on your cloud instance. 23 | -------------------------------------------------------------------------------- /backend/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-shared" 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. -------------------------------------------------------------------------------- /backend/src/admin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "module": "esnext" 5 | }, 6 | "include": ["."], 7 | "exclude": ["**/*.spec.js"] 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/api/admin/custom/route.ts: -------------------------------------------------------------------------------- 1 | import { MedusaRequest, MedusaResponse } from "@medusajs/framework"; 2 | 3 | export async function GET( 4 | req: MedusaRequest, 5 | res: MedusaResponse 6 | ): Promise { 7 | res.sendStatus(200); 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/api/key-exchange/route.ts: -------------------------------------------------------------------------------- 1 | import { MedusaRequest, MedusaResponse } from "@medusajs/framework"; 2 | import { IApiKeyModuleService } from '@medusajs/framework/types'; 3 | import { Modules } from '@medusajs/framework/utils'; 4 | 5 | export const GET = async (req: MedusaRequest, res: MedusaResponse) => { 6 | try { 7 | const apiKeyModuleService: IApiKeyModuleService = req.scope.resolve(Modules.API_KEY); 8 | const apiKeys = await apiKeyModuleService.listApiKeys(); 9 | const defaultApiKey = apiKeys.find((apiKey) => apiKey.title === 'Webshop'); 10 | if (!defaultApiKey) { 11 | res.json({}); 12 | } else { 13 | res.json({ publishableApiKey: defaultApiKey.token }); 14 | } 15 | } catch (error) { 16 | res.status(500).json({ error: error.message }); 17 | } 18 | } -------------------------------------------------------------------------------- /backend/src/api/store/custom/route.ts: -------------------------------------------------------------------------------- 1 | import { MedusaRequest, MedusaResponse } from "@medusajs/framework"; 2 | 3 | export async function GET( 4 | req: MedusaRequest, 5 | res: MedusaResponse 6 | ): Promise { 7 | res.sendStatus(200); 8 | } 9 | 10 | -------------------------------------------------------------------------------- /backend/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 | IProductModuleService, 12 | MedusaContainer 13 | } from "@medusajs/types"; 14 | import { ModuleRegistrationName } from "@medusajs/utils"; 15 | 16 | export default async function myCustomJob(container: MedusaContainer) { 17 | const productService: IProductModuleService = container.resolve(ModuleRegistrationName.PRODUCT) 18 | 19 | const products = await productService.listAndCountProducts(); 20 | 21 | // Do something with the products 22 | } 23 | 24 | export const config = { 25 | name: "daily-product-report", 26 | schedule: "0 0 * * *", // Every day at midnight 27 | }; 28 | ``` 29 | 30 | A scheduled job file must export: 31 | 32 | - The function to be executed whenever it’s time to run the scheduled job. 33 | - A configuration object defining the job. It has three properties: 34 | - `name`: a unique name for the job. 35 | - `schedule`: a [cron expression](https://crontab.guru/). 36 | - `numberOfExecutions`: an optional integer, specifying how many times the job will execute before being removed 37 | 38 | The `handler` is a function that accepts one parameter, `container`, which is a `MedusaContainer` instance used to resolve services. 39 | -------------------------------------------------------------------------------- /backend/src/modules/email-notifications/index.ts: -------------------------------------------------------------------------------- 1 | import { ModuleProviderExports } from '@medusajs/framework/types' 2 | import { ResendNotificationService } from './services/resend' 3 | 4 | const services = [ResendNotificationService] 5 | 6 | const providerExport: ModuleProviderExports = { 7 | services, 8 | } 9 | 10 | export default providerExport 11 | -------------------------------------------------------------------------------- /backend/src/modules/email-notifications/templates/base.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Body, Container, Preview, Tailwind, Head } from '@react-email/components' 2 | import * as React from 'react' 3 | 4 | interface BaseProps { 5 | preview?: string 6 | children: React.ReactNode 7 | } 8 | 9 | export const Base: React.FC = ({ preview, children }) => { 10 | return ( 11 | 12 | 13 | {preview} 14 | 15 | 16 | 17 |
18 | {children} 19 |
20 |
21 | 22 |
23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/modules/email-notifications/templates/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react' 2 | import { MedusaError } from '@medusajs/framework/utils' 3 | import { InviteUserEmail, INVITE_USER, isInviteUserData } from './invite-user' 4 | import { OrderPlacedTemplate, ORDER_PLACED, isOrderPlacedTemplateData } from './order-placed' 5 | 6 | export const EmailTemplates = { 7 | INVITE_USER, 8 | ORDER_PLACED 9 | } as const 10 | 11 | export type EmailTemplateType = keyof typeof EmailTemplates 12 | 13 | export function generateEmailTemplate(templateKey: string, data: unknown): ReactNode { 14 | switch (templateKey) { 15 | case EmailTemplates.INVITE_USER: 16 | if (!isInviteUserData(data)) { 17 | throw new MedusaError( 18 | MedusaError.Types.INVALID_DATA, 19 | `Invalid data for template "${EmailTemplates.INVITE_USER}"` 20 | ) 21 | } 22 | return 23 | 24 | case EmailTemplates.ORDER_PLACED: 25 | if (!isOrderPlacedTemplateData(data)) { 26 | throw new MedusaError( 27 | MedusaError.Types.INVALID_DATA, 28 | `Invalid data for template "${EmailTemplates.ORDER_PLACED}"` 29 | ) 30 | } 31 | return 32 | 33 | default: 34 | throw new MedusaError( 35 | MedusaError.Types.INVALID_DATA, 36 | `Unknown template key: "${templateKey}"` 37 | ) 38 | } 39 | } 40 | 41 | export { InviteUserEmail, OrderPlacedTemplate } 42 | -------------------------------------------------------------------------------- /backend/src/modules/minio-file/index.ts: -------------------------------------------------------------------------------- 1 | import { ModuleProviderExports } from '@medusajs/framework/types' 2 | import MinioFileProviderService from './service' 3 | 4 | const services = [MinioFileProviderService] 5 | 6 | const providerExport: ModuleProviderExports = { 7 | services, 8 | } 9 | 10 | export default providerExport 11 | -------------------------------------------------------------------------------- /backend/src/scripts/README.md: -------------------------------------------------------------------------------- 1 | # Custom CLI Script 2 | 3 | A custom CLI script is a function to execute through Medusa's CLI tool. This is useful when creating custom Medusa tooling to run as a CLI tool. 4 | 5 | ## How to Create a Custom CLI Script? 6 | 7 | To create a custom CLI script, create a TypeScript or JavaScript file under the `src/scripts` directory. The file must default export a function. 8 | 9 | For example, create the file `src/scripts/my-script.ts` with the following content: 10 | 11 | ```ts title="src/scripts/my-script.ts" 12 | import { 13 | ExecArgs, 14 | IProductModuleService 15 | } from "@medusajs/types" 16 | import { ModuleRegistrationName } from "@medusajs/utils" 17 | 18 | export default async function myScript ({ 19 | container 20 | }: ExecArgs) { 21 | const productModuleService: IProductModuleService = 22 | container.resolve(ModuleRegistrationName.PRODUCT) 23 | 24 | const [, count] = await productModuleService.listAndCount() 25 | 26 | console.log(`You have ${count} product(s)`) 27 | } 28 | ``` 29 | 30 | The function receives as a parameter an object having a `container` property, which is an instance of the Medusa Container. Use it to resolve resources in your Medusa application. 31 | 32 | --- 33 | 34 | ## How to Run Custom CLI Script? 35 | 36 | To run the custom CLI script, run the `exec` command: 37 | 38 | ```bash 39 | npx medusa exec ./src/scripts/my-script.ts 40 | ``` 41 | 42 | --- 43 | 44 | ## Custom CLI Script Arguments 45 | 46 | Your script can accept arguments from the command line. Arguments are passed to the function's object parameter in the `args` property. 47 | 48 | For example: 49 | 50 | ```ts 51 | import { ExecArgs } from "@medusajs/types" 52 | 53 | export default async function myScript ({ 54 | args 55 | }: ExecArgs) { 56 | console.log(`The arguments you passed: ${args}`) 57 | } 58 | ``` 59 | 60 | Then, pass the arguments in the `exec` command after the file path: 61 | 62 | ```bash 63 | npx medusa exec ./src/scripts/my-script.ts arg1 arg2 64 | ``` -------------------------------------------------------------------------------- /backend/src/scripts/postBuild.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { execSync } = require('child_process'); 3 | const path = require('path'); 4 | 5 | const MEDUSA_SERVER_PATH = path.join(process.cwd(), '.medusa', 'server'); 6 | 7 | // Check if .medusa/server exists - if not, build process failed 8 | if (!fs.existsSync(MEDUSA_SERVER_PATH)) { 9 | throw new Error('.medusa/server directory not found. This indicates the Medusa build process failed. Please check for build errors.'); 10 | } 11 | 12 | // Copy pnpm-lock.yaml 13 | fs.copyFileSync( 14 | path.join(process.cwd(), 'pnpm-lock.yaml'), 15 | path.join(MEDUSA_SERVER_PATH, 'pnpm-lock.yaml') 16 | ); 17 | 18 | // Copy .env if it exists 19 | const envPath = path.join(process.cwd(), '.env'); 20 | if (fs.existsSync(envPath)) { 21 | fs.copyFileSync( 22 | envPath, 23 | path.join(MEDUSA_SERVER_PATH, '.env') 24 | ); 25 | } 26 | 27 | // Install dependencies 28 | console.log('Installing dependencies in .medusa/server...'); 29 | execSync('pnpm i --prod --frozen-lockfile', { 30 | cwd: MEDUSA_SERVER_PATH, 31 | stdio: 'inherit' 32 | }); 33 | -------------------------------------------------------------------------------- /backend/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/medusa" 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/medusa" 42 | import { IProductModuleService } from "@medusajs/types" 43 | import { ModuleRegistrationName } from "@medusajs/utils" 44 | 45 | export default async function productCreateHandler({ 46 | event: { data }, 47 | container, 48 | }: SubscriberArgs<{ id: string }>) { 49 | const productId = data.id 50 | 51 | const productModuleService: IProductModuleService = 52 | container.resolve(ModuleRegistrationName.PRODUCT) 53 | 54 | const product = await productModuleService.retrieve(productId) 55 | 56 | console.log(`The product ${product.title} was created`) 57 | } 58 | 59 | export const config: SubscriberConfig = { 60 | event: "product.created", 61 | } 62 | ``` -------------------------------------------------------------------------------- /backend/src/subscribers/invite-created.ts: -------------------------------------------------------------------------------- 1 | import { INotificationModuleService, IUserModuleService } from '@medusajs/framework/types' 2 | import { Modules } from '@medusajs/framework/utils' 3 | import { SubscriberArgs, SubscriberConfig } from '@medusajs/framework' 4 | import { BACKEND_URL } from '../lib/constants' 5 | import { EmailTemplates } from '../modules/email-notifications/templates' 6 | 7 | export default async function userInviteHandler({ 8 | event: { data }, 9 | container, 10 | }: SubscriberArgs) { 11 | 12 | const notificationModuleService: INotificationModuleService = container.resolve( 13 | Modules.NOTIFICATION, 14 | ) 15 | const userModuleService: IUserModuleService = container.resolve(Modules.USER) 16 | const invite = await userModuleService.retrieveInvite(data.id) 17 | 18 | try { 19 | await notificationModuleService.createNotifications({ 20 | to: invite.email, 21 | channel: 'email', 22 | template: EmailTemplates.INVITE_USER, 23 | data: { 24 | emailOptions: { 25 | replyTo: 'info@example.com', 26 | subject: "You've been invited to Medusa!" 27 | }, 28 | inviteLink: `${BACKEND_URL}/app/invite?token=${invite.token}`, 29 | preview: 'The administration dashboard awaits...' 30 | } 31 | }) 32 | } catch (error) { 33 | console.error(error) 34 | } 35 | } 36 | 37 | export const config: SubscriberConfig = { 38 | event: ['invite.created', 'invite.resent'] 39 | } 40 | -------------------------------------------------------------------------------- /backend/src/subscribers/order-placed.ts: -------------------------------------------------------------------------------- 1 | import { Modules } from '@medusajs/framework/utils' 2 | import { INotificationModuleService, IOrderModuleService } from '@medusajs/framework/types' 3 | import { SubscriberArgs, SubscriberConfig } from '@medusajs/medusa' 4 | import { EmailTemplates } from '../modules/email-notifications/templates' 5 | 6 | export default async function orderPlacedHandler({ 7 | event: { data }, 8 | container, 9 | }: SubscriberArgs) { 10 | const notificationModuleService: INotificationModuleService = container.resolve(Modules.NOTIFICATION) 11 | const orderModuleService: IOrderModuleService = container.resolve(Modules.ORDER) 12 | 13 | const order = await orderModuleService.retrieveOrder(data.id, { relations: ['items', 'summary', 'shipping_address'] }) 14 | const shippingAddress = await (orderModuleService as any).orderAddressService_.retrieve(order.shipping_address.id) 15 | 16 | try { 17 | await notificationModuleService.createNotifications({ 18 | to: order.email, 19 | channel: 'email', 20 | template: EmailTemplates.ORDER_PLACED, 21 | data: { 22 | emailOptions: { 23 | replyTo: 'info@example.com', 24 | subject: 'Your order has been placed' 25 | }, 26 | order, 27 | shippingAddress, 28 | preview: 'Thank you for your order!' 29 | } 30 | }) 31 | } catch (error) { 32 | console.error('Error sending order confirmation notification:', error) 33 | } 34 | } 35 | 36 | export const config: SubscriberConfig = { 37 | event: 'order.placed' 38 | } 39 | -------------------------------------------------------------------------------- /backend/src/utils/assert-value.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Assert that a value is not undefined. If it is, throw an error with the provided message. 3 | * @param v - Value to assert 4 | * @param errorMessage - Error message to throw if value is undefined 5 | */ 6 | export function assertValue( 7 | v: T | undefined, 8 | errorMessage: string, 9 | ): T { 10 | if (v === undefined) { 11 | throw new Error(errorMessage) 12 | } 13 | 14 | return v 15 | } 16 | -------------------------------------------------------------------------------- /backend/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 | StepResponse, 14 | } from "@medusajs/workflows-sdk" 15 | 16 | const step1 = createStep("step-1", async () => { 17 | return new StepResponse(`Hello from step one!`) 18 | }) 19 | 20 | type WorkflowInput = { 21 | name: string 22 | } 23 | 24 | const step2 = createStep( 25 | "step-2", 26 | async ({ name }: WorkflowInput) => { 27 | return new StepResponse(`Hello ${name} from step two!`) 28 | } 29 | ) 30 | 31 | type WorkflowOutput = { 32 | message: string 33 | } 34 | 35 | const myWorkflow = createWorkflow< 36 | WorkflowInput, 37 | WorkflowOutput 38 | >("hello-world", function (input) { 39 | const str1 = step1() 40 | // to pass input 41 | step2(input) 42 | 43 | return { 44 | message: str1, 45 | } 46 | }) 47 | 48 | export default myWorkflow 49 | ``` 50 | 51 | ## Execute Workflow 52 | 53 | You can execute the workflow from other resources, such as API routes, scheduled jobs, or subscribers. 54 | 55 | For example, to execute the workflow in an API route: 56 | 57 | ```ts 58 | import type { 59 | MedusaRequest, 60 | MedusaResponse, 61 | } from "@medusajs/medusa" 62 | import myWorkflow from "../../../workflows/hello-world" 63 | 64 | export async function GET( 65 | req: MedusaRequest, 66 | res: MedusaResponse 67 | ) { 68 | const { result } = await myWorkflow(req.scope) 69 | .run({ 70 | input: { 71 | name: req.query.name as string, 72 | }, 73 | }) 74 | 75 | res.send(result) 76 | } 77 | ``` 78 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2019", 4 | "allowJs": true, 5 | "esModuleInterop": true, 6 | "module": "node16", 7 | "moduleResolution": "node16", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "skipLibCheck": true, 11 | "skipDefaultLibCheck": true, 12 | "declaration": true, 13 | "sourceMap": false, 14 | "outDir": "./.medusa/server", 15 | "rootDir": "./", 16 | "baseUrl": ".", 17 | "paths": { 18 | "*": ["./src/*"] 19 | }, 20 | "jsx": "react-jsx", 21 | "forceConsistentCasingInFileNames": true, 22 | "resolveJsonModule": true, 23 | "checkJs": false 24 | }, 25 | "ts-node": { 26 | "swc": true 27 | }, 28 | "include": ["src", "medusa-config.js"], 29 | "exclude": [ 30 | "**/__tests__", 31 | "**/__fixtures__", 32 | "node_modules", 33 | "build", 34 | ".cache" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /storefront/.env.local.template: -------------------------------------------------------------------------------- 1 | # Your Medusa backend, should be updated to where you are hosting your server. Remember to update CORS settings for your server. 2 | NEXT_PUBLIC_MEDUSA_BACKEND_URL=http://localhost:9000 3 | 4 | # Your store URL, should be updated to where you are hosting your storefront. 5 | NEXT_PUBLIC_BASE_URL=http://localhost:8000 6 | 7 | # Your preferred default region. When middleware cannot determine the user region from the request IP, this will be used instead. 8 | NEXT_PUBLIC_DEFAULT_REGION=us 9 | 10 | # Minio endpoint for image storage (optional) 11 | # NEXT_PUBLIC_MINIO_ENDPOINT=bucket-production-eaeb.up.railway.app 12 | 13 | # MeiliSearch Configuration 14 | # The URL of your MeiliSearch instance (default: http://localhost:7700 or auto-configured on Railway) 15 | NEXT_PUBLIC_SEARCH_ENDPOINT=http://localhost:7700 16 | 17 | # Your MeiliSearch search key (auto-configured on Railway) 18 | NEXT_PUBLIC_SEARCH_API_KEY=your_search_key_here 19 | 20 | # The name of the index in MeiliSearch (matches backend configuration) 21 | NEXT_PUBLIC_INDEX_NAME=products 22 | -------------------------------------------------------------------------------- /storefront/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["next", "next/core-web-vitals"] 3 | }; -------------------------------------------------------------------------------- /storefront/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # IDEs 4 | .idea 5 | .vscode 6 | 7 | # dependencies 8 | /node_modules 9 | /.pnp 10 | .pnp.js 11 | 12 | # testing 13 | /coverage 14 | 15 | # next.js 16 | /.next/ 17 | /out/ 18 | 19 | # production 20 | /build 21 | 22 | # misc 23 | .DS_Store 24 | *.pem 25 | 26 | # debug 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | .pnpm-debug.log* 31 | 32 | # local env files 33 | .env 34 | .env.local 35 | .env.development.local 36 | .env.test.local 37 | .env.production.local 38 | 39 | # vercel 40 | .vercel 41 | 42 | # typescript 43 | *.tsbuildinfo 44 | node_modules 45 | 46 | .yarn 47 | .swc 48 | dump.rdb 49 | /test-results/ 50 | /playwright-report/ 51 | /blob-report/ 52 | /playwright/.cache/ 53 | /test-results/ 54 | /playwright-report/ 55 | /blob-report/ 56 | /playwright/.cache/ 57 | /playwright/.auth 58 | -------------------------------------------------------------------------------- /storefront/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "semi": false, 4 | "endOfLine": "auto", 5 | "singleQuote": false, 6 | "tabWidth": 2, 7 | "trailingComma": "es5" 8 | } 9 | -------------------------------------------------------------------------------- /storefront/.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /storefront/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Medusa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /storefront/check-env-variables.js: -------------------------------------------------------------------------------- 1 | const c = require("ansi-colors") 2 | 3 | const requiredEnvs = [ 4 | { 5 | key: "NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY", 6 | // TODO: we need a good doc to point this to 7 | description: 8 | "Learn how to create a publishable key: https://docs.medusajs.com/v2/resources/storefront-development/publishable-api-keys", 9 | }, 10 | ] 11 | 12 | function checkEnvVariables() { 13 | const missingEnvs = requiredEnvs.filter(function (env) { 14 | return !process.env[env.key] 15 | }) 16 | 17 | if (missingEnvs.length > 0) { 18 | console.error( 19 | c.red.bold("\n🚫 Error: Missing required environment variables\n") 20 | ) 21 | 22 | missingEnvs.forEach(function (env) { 23 | console.error(c.yellow(` ${c.bold(env.key)}`)) 24 | if (env.description) { 25 | console.error(c.dim(` ${env.description}\n`)) 26 | } 27 | }) 28 | 29 | console.error( 30 | c.yellow( 31 | "\nPlease set these variables in your .env file or environment before starting the application.\n" 32 | ) 33 | ) 34 | 35 | process.exit(1) 36 | } 37 | } 38 | 39 | module.exports = checkEnvVariables 40 | -------------------------------------------------------------------------------- /storefront/e2e/.env.example: -------------------------------------------------------------------------------- 1 | # Need a superuser to reset database 2 | PGHOST=localhost 3 | PGPORT=5432 4 | PGUSER=postgres 5 | PGPASSWORD=password 6 | PGDATABASE=postgres 7 | 8 | # Test database config 9 | TEST_POSTGRES_USER=test_medusa_user 10 | TEST_POSTGRES_DATABASE=test_medusa_db 11 | TEST_POSTGRES_DATABASE_TEMPLATE=test_medusa_db_template 12 | TEST_POSTGRES_HOST=localhost 13 | TEST_POSTGREST_PORT=5432 14 | PRODUCTION_POSTGRES_DATABASE=medusa_db 15 | 16 | # Backend server API 17 | CLIENT_SERVER=http://localhost:9000 18 | MEDUSA_ADMIN_EMAIL=admin@medusa-test.com 19 | MEDUSA_ADMIN_PASSWORD=supersecret -------------------------------------------------------------------------------- /storefront/e2e/fixtures/account/account-page.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test" 2 | import { BasePage } from "../base/base-page" 3 | 4 | export class AccountPage extends BasePage { 5 | container: Locator 6 | accountNav: Locator 7 | 8 | overviewLink: Locator 9 | profileLink: Locator 10 | addressesLink: Locator 11 | ordersLink: Locator 12 | logoutLink: Locator 13 | 14 | mobileAccountNav: Locator 15 | mobileAccountMainLink : Locator 16 | mobileOverviewLink : Locator 17 | mobileProfileLink : Locator 18 | mobileAddressesLink : Locator 19 | mobileOrdersLink : Locator 20 | mobileLogoutLink : Locator 21 | 22 | constructor(page: Page) { 23 | super(page) 24 | this.container = page.getByTestId("account-page") 25 | this.accountNav = this.container.getByTestId("account-nav") 26 | this.overviewLink = this.accountNav.getByTestId("overview-link") 27 | this.profileLink = this.accountNav.getByTestId("profile-link") 28 | this.addressesLink = this.accountNav.getByTestId("addresses-link") 29 | this.ordersLink = this.accountNav.getByTestId("orders-link") 30 | this.logoutLink = this.accountNav.getByTestId("logout-button") 31 | 32 | this.mobileAccountNav = this.container.getByTestId("mobile-account-nav") 33 | this.mobileAccountMainLink = this.mobileAccountNav.getByTestId("account-main-link") 34 | this.mobileOverviewLink = this.mobileAccountNav.getByTestId("overview-link") 35 | this.mobileProfileLink = this.mobileAccountNav.getByTestId("profile-link") 36 | this.mobileAddressesLink = this.mobileAccountNav.getByTestId("addresses-link") 37 | this.mobileOrdersLink = this.mobileAccountNav.getByTestId("orders-link") 38 | this.mobileLogoutLink = this.mobileAccountNav.getByTestId("logout-button") 39 | } 40 | 41 | async goto() { 42 | await this.navMenu.navAccountLink.click() 43 | await this.container.waitFor({ state: "visible" }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/account/addresses-page.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test" 2 | import { AccountPage } from "./account-page" 3 | import { AddressModal } from "./modals/address-modal" 4 | 5 | export class AddressesPage extends AccountPage { 6 | addAddressModal: AddressModal 7 | editAddressModal: AddressModal 8 | addressContainer: Locator 9 | addressesWrapper: Locator 10 | newAddressButton: Locator 11 | 12 | constructor(page: Page) { 13 | super(page) 14 | this.addAddressModal = new AddressModal(page, "add") 15 | this.editAddressModal = new AddressModal(page, "edit") 16 | this.addressContainer = this.container.getByTestId("address-container") 17 | this.addressesWrapper = page.getByTestId("addresses-page-wrapper") 18 | this.newAddressButton = this.container.getByTestId("add-address-button") 19 | } 20 | 21 | getAddressContainer(text: string) { 22 | const container = this.page 23 | .getByTestId("address-container") 24 | .filter({ hasText: text }) 25 | return { 26 | container, 27 | editButton: container.getByTestId('address-edit-button'), 28 | deleteButton: container.getByTestId("address-delete-button"), 29 | name: container.getByTestId("address-name"), 30 | company: container.getByTestId("address-company"), 31 | address: container.getByTestId("address-address"), 32 | postalCity: container.getByTestId("address-postal-city"), 33 | provinceCountry: container.getByTestId("address-province-country"), 34 | } 35 | } 36 | 37 | async goto() { 38 | await super.goto() 39 | await this.addressesLink.click() 40 | await this.addressesWrapper.waitFor({ state: "visible" }) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/account/index.ts: -------------------------------------------------------------------------------- 1 | import { test as base } from "@playwright/test" 2 | import { AddressesPage } from "./addresses-page" 3 | import { LoginPage } from "./login-page" 4 | import { OrderPage } from "./order-page" 5 | import { OrdersPage } from "./orders-page" 6 | import { OverviewPage } from "./overview-page" 7 | import { ProfilePage } from "./profile-page" 8 | import { RegisterPage } from "./register-page" 9 | 10 | export const accountFixtures = base.extend<{ 11 | accountAddressesPage: AddressesPage 12 | accountOrderPage: OrderPage 13 | accountOrdersPage: OrdersPage 14 | accountOverviewPage: OverviewPage 15 | accountProfilePage: ProfilePage 16 | loginPage: LoginPage 17 | registerPage: RegisterPage 18 | }>({ 19 | accountAddressesPage: async ({ page }, use) => { 20 | const addressesPage = new AddressesPage(page) 21 | await use(addressesPage) 22 | }, 23 | accountOrderPage: async ({ page }, use) => { 24 | const orderPage = new OrderPage(page) 25 | await use(orderPage) 26 | }, 27 | accountOrdersPage: async ({ page }, use) => { 28 | const ordersPage = new OrdersPage(page) 29 | await use(ordersPage) 30 | }, 31 | accountOverviewPage: async ({ page }, use) => { 32 | const overviewPage = new OverviewPage(page) 33 | await use(overviewPage) 34 | }, 35 | accountProfilePage: async ({ page }, use) => { 36 | const profilePage = new ProfilePage(page) 37 | await use(profilePage) 38 | }, 39 | loginPage: async ({ page }, use) => { 40 | const loginPage = new LoginPage(page) 41 | await use(loginPage) 42 | }, 43 | registerPage: async ({ page }, use) => { 44 | const registerPage = new RegisterPage(page) 45 | await use(registerPage) 46 | }, 47 | }) 48 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/account/login-page.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test" 2 | import { BasePage } from "../base/base-page" 3 | 4 | export class LoginPage extends BasePage { 5 | container: Locator 6 | emailInput: Locator 7 | passwordInput: Locator 8 | signInButton: Locator 9 | registerButton: Locator 10 | errorMessage: Locator 11 | 12 | constructor(page: Page) { 13 | super(page) 14 | this.container = page.getByTestId("login-page") 15 | this.emailInput = this.container.getByTestId("email-input") 16 | this.passwordInput = this.container.getByTestId("password-input") 17 | this.signInButton = this.container.getByTestId("sign-in-button") 18 | this.registerButton = this.container.getByTestId("register-button") 19 | this.errorMessage = this.container.getByTestId("login-error-message") 20 | } 21 | 22 | async goto() { 23 | await this.page.goto("/account") 24 | await this.container.waitFor({ state: "visible" }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/account/modals/address-modal.ts: -------------------------------------------------------------------------------- 1 | import { Page, Locator } from "@playwright/test" 2 | import { BaseModal } from "../../base/base-modal" 3 | 4 | export class AddressModal extends BaseModal { 5 | saveButton: Locator 6 | cancelButton: Locator 7 | 8 | firstNameInput: Locator 9 | lastNameInput: Locator 10 | companyInput: Locator 11 | address1Input: Locator 12 | address2Input: Locator 13 | postalCodeInput: Locator 14 | cityInput: Locator 15 | stateInput: Locator 16 | countrySelect: Locator 17 | phoneInput: Locator 18 | 19 | constructor(page: Page, modalType: "add" | "edit") { 20 | if (modalType === "add") { 21 | super(page, page.getByTestId("add-address-modal")) 22 | } else { 23 | super(page, page.getByTestId("edit-address-modal")) 24 | } 25 | 26 | this.saveButton = this.container.getByTestId("save-button") 27 | this.cancelButton = this.container.getByTestId("cancel-button") 28 | 29 | this.firstNameInput = this.container.getByTestId("first-name-input") 30 | this.lastNameInput = this.container.getByTestId("last-name-input") 31 | this.companyInput = this.container.getByTestId("company-input") 32 | this.address1Input = this.container.getByTestId("address-1-input") 33 | this.address2Input = this.container.getByTestId("address-2-input") 34 | this.postalCodeInput = this.container.getByTestId("postal-code-input") 35 | this.cityInput = this.container.getByTestId("city-input") 36 | this.stateInput = this.container.getByTestId("state-input") 37 | this.countrySelect = this.container.getByTestId("country-select") 38 | this.phoneInput = this.container.getByTestId("phone-input") 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/account/orders-page.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test" 2 | import { AccountPage } from "./account-page" 3 | 4 | export class OrdersPage extends AccountPage { 5 | ordersWrapper: Locator 6 | noOrdersContainer: Locator 7 | continueShoppingButton: Locator 8 | orderCard: Locator 9 | orderDisplayId: Locator 10 | 11 | constructor(page: Page) { 12 | super(page) 13 | this.ordersWrapper = page.getByTestId("orders-page-wrapper") 14 | this.noOrdersContainer = page.getByTestId("no-orders-container") 15 | this.continueShoppingButton = page.getByTestId("continue-shopping-button") 16 | this.orderCard = page.getByTestId("order-card") 17 | this.orderDisplayId = page.getByTestId("order-display-id") 18 | 19 | this.orderCard = page.getByTestId("order-card") 20 | this.orderDisplayId = page.getByTestId("order-display-id") 21 | } 22 | 23 | async getOrderById(orderId: string) { 24 | const orderIdLocator = this.page 25 | .getByTestId("order-display-id") 26 | .filter({ 27 | hasText: orderId, 28 | }) 29 | .first() 30 | const card = this.orderCard.filter({ has: orderIdLocator }).first() 31 | const items = (await card.getByTestId("order-item").all()).map( 32 | (orderItem) => { 33 | return { 34 | item: orderItem, 35 | title: orderItem.getByTestId("item-title"), 36 | quantity: orderItem.getByTestId("item-quantity"), 37 | } 38 | } 39 | ) 40 | return { 41 | card, 42 | displayId: card.getByTestId("order-display-id"), 43 | createdAt: card.getByTestId("order-created-at"), 44 | orderId: card.getByTestId("order-display-id"), 45 | amount: card.getByTestId("order-amount"), 46 | detailsLink: card.getByTestId("order-details-link"), 47 | itemsLocator: card.getByTestId("order-item"), 48 | items, 49 | } 50 | } 51 | 52 | async goto() { 53 | await super.goto() 54 | await this.ordersLink.click() 55 | await this.ordersWrapper.waitFor({ state: "visible" }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/account/overview-page.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test" 2 | import { AccountPage } from "./account-page" 3 | 4 | export class OverviewPage extends AccountPage { 5 | welcomeMessage: Locator 6 | customerEmail: Locator 7 | profileCompletion: Locator 8 | addressesCount: Locator 9 | noOrdersMessage: Locator 10 | ordersWrapper: Locator 11 | orderWrapper: Locator 12 | overviewWrapper: Locator 13 | 14 | constructor(page: Page) { 15 | super(page) 16 | this.overviewWrapper = this.container.getByTestId("overview-page-wrapper") 17 | this.welcomeMessage = this.container.getByTestId("welcome-message") 18 | this.customerEmail = this.container.getByTestId("customer-email") 19 | this.profileCompletion = this.container.getByTestId( 20 | "customer-profile-completion" 21 | ) 22 | this.addressesCount = this.container.getByTestId("addresses-count") 23 | this.noOrdersMessage = this.container.getByTestId("no-orders-message") 24 | this.ordersWrapper = this.container.getByTestId("orders-wrapper") 25 | this.orderWrapper = this.container.getByTestId("order-wrapper") 26 | } 27 | 28 | async getOrder(orderId: string) { 29 | const order = this.ordersWrapper.locator( 30 | `[data-testid="order-wrapper"][data-value="${orderId}"]` 31 | ) 32 | return { 33 | locator: order, 34 | id: await order.getAttribute("value"), 35 | createdDate: await order.getByTestId("order-created-date"), 36 | displayId: await order.getByTestId("order-id").getAttribute("value"), 37 | amount: await order.getByTestId("order-amount").textContent(), 38 | openButton: order.getByTestId("open-order-button"), 39 | } 40 | } 41 | 42 | async goto() { 43 | await this.navMenu.navAccountLink.click() 44 | await this.container.waitFor({ state: "visible" }) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/account/register-page.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test" 2 | import { BasePage } from "../base/base-page" 3 | 4 | export class RegisterPage extends BasePage { 5 | container: Locator 6 | firstNameInput: Locator 7 | lastNameInput: Locator 8 | emailInput: Locator 9 | phoneInput: Locator 10 | passwordInput: Locator 11 | registerButton: Locator 12 | registerError: Locator 13 | loginLink: Locator 14 | 15 | constructor(page: Page) { 16 | super(page) 17 | this.container = page.getByTestId("register-page") 18 | this.firstNameInput = this.container.getByTestId("first-name-input") 19 | this.lastNameInput = this.container.getByTestId("last-name-input") 20 | this.emailInput = this.container.getByTestId("email-input") 21 | this.phoneInput = this.container.getByTestId("phone-input") 22 | this.passwordInput = this.container.getByTestId("password-input") 23 | this.registerButton = this.container.getByTestId("register-button") 24 | this.registerError = this.container.getByTestId("register-error") 25 | this.loginLink = this.container.getByTestId("login-link") 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/base/base-modal.ts: -------------------------------------------------------------------------------- 1 | import { Page, Locator } from "@playwright/test" 2 | 3 | export class BaseModal { 4 | page: Page 5 | container: Locator 6 | closeButton: Locator 7 | 8 | constructor(page: Page, container: Locator) { 9 | this.page = page 10 | this.container = container 11 | this.closeButton = this.container.getByTestId("close-modal-button") 12 | } 13 | 14 | async close() { 15 | const button = this.container.getByTestId("close-modal-button") 16 | await button.click() 17 | } 18 | 19 | async isOpen() { 20 | return await this.container.isVisible() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/base/base-page.ts: -------------------------------------------------------------------------------- 1 | import { CartDropdown } from "./cart-dropdown" 2 | import { NavMenu } from "./nav-menu" 3 | import { Page, Locator } from "@playwright/test" 4 | import { SearchModal } from "./search-modal" 5 | 6 | export class BasePage { 7 | page: Page 8 | navMenu: NavMenu 9 | cartDropdown: CartDropdown 10 | searchModal: SearchModal 11 | accountLink: Locator 12 | cartLink: Locator 13 | searchLink: Locator 14 | storeLink: Locator 15 | categoriesList: Locator 16 | 17 | constructor(page: Page) { 18 | this.page = page 19 | this.navMenu = new NavMenu(page) 20 | this.cartDropdown = new CartDropdown(page) 21 | this.searchModal = new SearchModal(page) 22 | this.accountLink = page.getByTestId("nav-account-link") 23 | this.cartLink = page.getByTestId("nav-cart-link") 24 | this.storeLink = page.getByTestId("nav-store-link") 25 | this.searchLink = page.getByTestId("nav-search-link") 26 | this.categoriesList = page.getByTestId("footer-categories") 27 | } 28 | 29 | async clickCategoryLink(category: string) { 30 | const link = this.categoriesList.getByTestId("category-link") 31 | await link.click() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/base/cart-dropdown.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test" 2 | 3 | export class CartDropdown { 4 | page: Page 5 | navCartLink: Locator 6 | cartDropdown: Locator 7 | cartSubtotal: Locator 8 | goToCartButton: Locator 9 | 10 | constructor(page: Page) { 11 | this.page = page 12 | this.navCartLink = page.getByTestId("nav-cart-link") 13 | this.cartDropdown = page.getByTestId("nav-cart-dropdown") 14 | this.cartSubtotal = this.cartDropdown.getByTestId("cart-subtotal") 15 | this.goToCartButton = this.cartDropdown.getByTestId("go-to-cart-button") 16 | } 17 | 18 | async displayCart() { 19 | await this.navCartLink.hover() 20 | } 21 | 22 | async close() { 23 | if (await this.cartDropdown.isVisible()) { 24 | const box = await this.cartDropdown.boundingBox() 25 | if (!box) { 26 | return 27 | } 28 | await this.page.mouse.move(box.x + box.width / 4, box.y + box.height / 4) 29 | await this.page.mouse.move(5, 10) 30 | } 31 | } 32 | 33 | async getCartItem(name: string, variant: string) { 34 | const cartItem = this.cartDropdown 35 | .getByTestId("cart-item") 36 | .filter({ 37 | hasText: name, 38 | }) 39 | .filter({ 40 | hasText: `Variant: ${variant}`, 41 | }) 42 | return { 43 | locator: cartItem, 44 | productLink: cartItem.getByTestId("product-link"), 45 | removeButton: cartItem.getByTestId("cart-item-remove-button"), 46 | name, 47 | quantity: cartItem.getByTestId("cart-item-quantity"), 48 | variant: cartItem.getByTestId("cart-item-variant"), 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/base/nav-menu.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test" 2 | 3 | export class NavMenu { 4 | page: Page 5 | navMenuButton: Locator 6 | navMenu: Locator 7 | navAccountLink: Locator 8 | homeLink: Locator 9 | storeLink: Locator 10 | searchLink: Locator 11 | accountLink: Locator 12 | cartLink: Locator 13 | closeButton: Locator 14 | shippingToLink: Locator 15 | shippingToMenu: Locator 16 | 17 | constructor(page: Page) { 18 | this.page = page 19 | this.navMenuButton = page.getByTestId("nav-menu-button") 20 | this.navMenu = page.getByTestId("nav-menu-popup") 21 | this.navAccountLink = page.getByTestId("nav-account-link") 22 | this.homeLink = this.navMenu.getByTestId("home-link") 23 | this.storeLink = this.navMenu.getByTestId("store-link") 24 | this.searchLink = this.navMenu.getByTestId("search-link") 25 | this.accountLink = this.navMenu.getByTestId("account-link") 26 | this.cartLink = this.navMenu.getByTestId("nav-cart-link") 27 | this.closeButton = this.navMenu.getByTestId("close-menu-button") 28 | this.shippingToLink = this.navMenu.getByTestId("shipping-to-button") 29 | this.shippingToMenu = this.navMenu.getByTestId("shipping-to-choices") 30 | } 31 | 32 | async selectShippingCountry(country: string) { 33 | if (!(await this.navMenu.isVisible())) { 34 | throw { 35 | error: 36 | `You cannot call ` + 37 | `NavMenu.selectShippingCountry("${country}") without having the ` + 38 | `navMenu visible first!`, 39 | } 40 | } 41 | const countryLink = this.navMenu.getByTestId( 42 | `select-${country.toLowerCase()}-choice` 43 | ) 44 | await this.shippingToLink.hover() 45 | await this.shippingToMenu.waitFor({ 46 | state: "visible", 47 | }) 48 | await countryLink.click() 49 | } 50 | 51 | async open() { 52 | await this.navMenuButton.click() 53 | await this.navMenu.waitFor({ state: "visible" }) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/base/search-modal.ts: -------------------------------------------------------------------------------- 1 | import { Page, Locator } from "@playwright/test" 2 | import { BaseModal } from "./base-modal" 3 | import { NavMenu } from "./nav-menu" 4 | 5 | export class SearchModal extends BaseModal { 6 | searchInput: Locator 7 | searchResults: Locator 8 | noSearchResultsContainer: Locator 9 | searchResult: Locator 10 | searchResultTitle: Locator 11 | 12 | constructor(page: Page) { 13 | super(page, page.getByTestId("search-modal-container")) 14 | this.searchInput = this.container.getByTestId("search-input") 15 | this.searchResults = this.container.getByTestId("search-results") 16 | this.noSearchResultsContainer = this.container.getByTestId( 17 | "no-search-results-container" 18 | ) 19 | this.searchResult = this.container.getByTestId("search-result") 20 | this.searchResultTitle = this.container.getByTestId("search-result-title") 21 | } 22 | 23 | async open() { 24 | const menu = new NavMenu(this.page) 25 | await menu.open() 26 | await menu.searchLink.click() 27 | await this.container.waitFor({ state: "visible" }) 28 | } 29 | 30 | async close() { 31 | const viewport = this.page.viewportSize() 32 | const y = viewport ? viewport.height / 2 : 100 33 | await this.page.mouse.click(1, y, { clickCount: 2, delay: 100 }) 34 | await this.container.waitFor({ state: "hidden" }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/category-page.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test" 2 | import { BasePage } from "./base/base-page" 3 | 4 | export class CategoryPage extends BasePage { 5 | container: Locator 6 | sortByContainer: Locator 7 | 8 | pageTitle: Locator 9 | pagination: Locator 10 | productsListLoader: Locator 11 | productsList: Locator 12 | productWrapper: Locator 13 | 14 | constructor(page: Page) { 15 | super(page) 16 | this.container = page.getByTestId("category-container") 17 | this.pageTitle = page.getByTestId("category-page-title") 18 | this.sortByContainer = page.getByTestId("sort-by-container") 19 | this.productsListLoader = this.container.getByTestId("products-list-loader") 20 | this.productsList = this.container.getByTestId("products-list") 21 | this.productWrapper = this.productsList.getByTestId("product-wrapper") 22 | this.pagination = this.container.getByTestId("product-pagination") 23 | } 24 | 25 | async getProduct(name: string) { 26 | const product = this.productWrapper.filter({ hasText: name }) 27 | return { 28 | locator: product, 29 | title: product.getByTestId("product-title"), 30 | price: product.getByTestId("price"), 31 | originalPrice: product.getByTestId("original-price"), 32 | } 33 | } 34 | 35 | async sortBy(sortString: string) { 36 | const link = this.sortByContainer.getByTestId("sort-by-link").filter({ 37 | hasText: sortString, 38 | }) 39 | await link.click() 40 | // wait for page change 41 | await this.page.waitForFunction((linkElement) => { 42 | if (!linkElement) { 43 | return true 44 | } 45 | return linkElement.dataset.active === "true" 46 | }, await link.elementHandle()) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/index.ts: -------------------------------------------------------------------------------- 1 | import { test as base, Page } from "@playwright/test" 2 | import { resetDatabase } from "../data/reset" 3 | import { CartPage } from "./cart-page" 4 | import { CategoryPage } from "./category-page" 5 | import { CheckoutPage } from "./checkout-page" 6 | import { OrderPage } from "./order-page" 7 | import { ProductPage } from "./product-page" 8 | import { StorePage } from "./store-page" 9 | 10 | export const fixtures = base.extend<{ 11 | resetDatabaseFixture: void 12 | cartPage: CartPage 13 | categoryPage: CategoryPage 14 | checkoutPage: CheckoutPage 15 | orderPage: OrderPage 16 | productPage: ProductPage 17 | storePage: StorePage 18 | }>({ 19 | page: async ({ page }, use) => { 20 | await page.goto("/") 21 | use(page) 22 | }, 23 | resetDatabaseFixture: [ 24 | async function ({}, use) { 25 | await resetDatabase() 26 | await use() 27 | }, 28 | { auto: true, timeout: 10000 }, 29 | ], 30 | cartPage: async ({ page }, use) => { 31 | const cartPage = new CartPage(page) 32 | await use(cartPage) 33 | }, 34 | categoryPage: async ({ page }, use) => { 35 | const categoryPage = new CategoryPage(page) 36 | await use(categoryPage) 37 | }, 38 | checkoutPage: async ({ page }, use) => { 39 | const checkoutPage = new CheckoutPage(page) 40 | await use(checkoutPage) 41 | }, 42 | orderPage: async ({ page }, use) => { 43 | const orderPage = new OrderPage(page) 44 | await use(orderPage) 45 | }, 46 | productPage: async ({ page }, use) => { 47 | const productPage = new ProductPage(page) 48 | await use(productPage) 49 | }, 50 | storePage: async ({ page }, use) => { 51 | const storePage = new StorePage(page) 52 | await use(storePage) 53 | }, 54 | }) 55 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/modals/mobile-actions-modal.ts: -------------------------------------------------------------------------------- 1 | import { Page, Locator } from "@playwright/test" 2 | import { BaseModal } from "../base/base-modal" 3 | 4 | export class MobileActionsModal extends BaseModal { 5 | optionButton: Locator 6 | 7 | constructor(page: Page) { 8 | super(page, page.getByTestId("mobile-actions-modal")) 9 | this.optionButton = this.container.getByTestId("option-button") 10 | } 11 | 12 | getOption(option: string) { 13 | return this.optionButton.filter({ 14 | hasText: option, 15 | }) 16 | } 17 | 18 | async selectOption(option: string) { 19 | const optionButton = this.getOption(option) 20 | await optionButton.click() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/product-page.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test" 2 | import { BasePage } from "./base/base-page" 3 | import { MobileActionsModal } from "./modals/mobile-actions-modal" 4 | 5 | export class ProductPage extends BasePage { 6 | mobileActionsModal: MobileActionsModal 7 | 8 | container: Locator 9 | productTitle: Locator 10 | productDescription: Locator 11 | productOptions: Locator 12 | productPrice: Locator 13 | addProductButton: Locator 14 | mobileActionsContainer: Locator 15 | mobileTitle: Locator 16 | mobileActionsButton: Locator 17 | mobileAddToCartButton: Locator 18 | 19 | constructor(page: Page) { 20 | super(page) 21 | 22 | this.mobileActionsModal = new MobileActionsModal(page) 23 | 24 | this.container = page.getByTestId("product-container") 25 | this.productTitle = this.container.getByTestId("product-title") 26 | this.productDescription = this.container.getByTestId("product-description") 27 | this.productOptions = this.container.getByTestId("product-options") 28 | this.productPrice = this.container.getByTestId("product-price") 29 | this.addProductButton = this.container.getByTestId("add-product-button") 30 | this.mobileActionsContainer = page.getByTestId("mobile-actions") 31 | this.mobileTitle = this.mobileActionsContainer.getByTestId("mobile-title") 32 | this.mobileAddToCartButton = this.mobileActionsContainer.getByTestId( 33 | "mobile-actions-button" 34 | ) 35 | this.mobileActionsButton = this.mobileActionsContainer.getByTestId( 36 | "mobile-actions-select" 37 | ) 38 | } 39 | 40 | async clickAddProduct() { 41 | await this.addProductButton.click() 42 | await this.cartDropdown.cartDropdown.waitFor({ state: "visible" }) 43 | } 44 | 45 | async selectOption(option: string) { 46 | await this.page.mouse.move(0, 0) // hides the checkout container 47 | const optionButton = this.productOptions 48 | .getByTestId("option-button") 49 | .filter({ hasText: option }) 50 | await optionButton.click({ clickCount: 2 }) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /storefront/e2e/fixtures/store-page.ts: -------------------------------------------------------------------------------- 1 | import { Locator, Page } from "@playwright/test" 2 | import { CategoryPage } from "./category-page" 3 | 4 | export class StorePage extends CategoryPage { 5 | pageTitle: Locator 6 | 7 | constructor(page: Page) { 8 | super(page) 9 | this.pageTitle = page.getByTestId("store-page-title") 10 | } 11 | 12 | async goto() { 13 | await this.navMenu.open() 14 | await this.navMenu.storeLink.click() 15 | await this.pageTitle.waitFor({ state: "visible" }) 16 | await this.productsListLoader.waitFor({ state: "hidden" }) 17 | } 18 | } -------------------------------------------------------------------------------- /storefront/e2e/index.ts: -------------------------------------------------------------------------------- 1 | import { mergeTests } from "@playwright/test" 2 | import { fixtures } from "./fixtures" 3 | import { accountFixtures } from "./fixtures/account" 4 | 5 | export const test = mergeTests(fixtures, accountFixtures) 6 | export { expect } from "@playwright/test" 7 | -------------------------------------------------------------------------------- /storefront/e2e/tests/global/public-setup.ts: -------------------------------------------------------------------------------- 1 | import { test as setup } from "@playwright/test" 2 | import { seedData } from "../../data/seed" 3 | 4 | setup("Seed data", async () => { 5 | await seedData() 6 | }) 7 | -------------------------------------------------------------------------------- /storefront/e2e/tests/global/setup.ts: -------------------------------------------------------------------------------- 1 | import { test as setup } from "@playwright/test" 2 | import { seedData } from "../../data/seed" 3 | import { OverviewPage as AccountOverviewPage } from "../../fixtures/account/overview-page" 4 | import { LoginPage } from "../../fixtures/account/login-page" 5 | import { STORAGE_STATE } from "../../../playwright.config" 6 | 7 | setup( 8 | "Seed data and create session for authenticated user", 9 | async ({ page }) => { 10 | const seed = await seedData() 11 | const user = seed.user 12 | 13 | const loginPage = new LoginPage(page) 14 | const accountPage = new AccountOverviewPage(page) 15 | await loginPage.goto() 16 | await loginPage.emailInput.fill(user?.email!) 17 | await loginPage.passwordInput.fill(user?.password!) 18 | await loginPage.signInButton.click() 19 | await accountPage.welcomeMessage.waitFor({ state: "visible" }) 20 | 21 | await page.context().storageState({ 22 | path: STORAGE_STATE, 23 | }) 24 | } 25 | ) 26 | -------------------------------------------------------------------------------- /storefront/e2e/tests/global/teardown.ts: -------------------------------------------------------------------------------- 1 | import { test as teardown } from "@playwright/test" 2 | import { dropTemplate, resetDatabase } from "../../data/reset" 3 | 4 | teardown("Reset the database and the drop the template database", async () => { 5 | await resetDatabase() 6 | await dropTemplate() 7 | }) 8 | -------------------------------------------------------------------------------- /storefront/e2e/utils/index.ts: -------------------------------------------------------------------------------- 1 | export function getFloatValue(s: string) { 2 | return parseFloat(parseFloat(s).toFixed(2)) 3 | } 4 | 5 | export function compareFloats(f1: number, f2: number) { 6 | const diff = f1 - f2 7 | if (Math.abs(diff) < 0.01) { 8 | return 0 9 | } else if (diff < 0) { 10 | return -1 11 | } else { 12 | return 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /storefront/e2e/utils/locators.ts: -------------------------------------------------------------------------------- 1 | import { Page, Locator} from '@playwright/test' 2 | 3 | export async function getSelectedOptionText(page: Page, select: Locator) { 4 | const handle = await select.elementHandle() 5 | return await page.evaluate( 6 | (opts) => { 7 | if (!opts || !opts[0]) { return "" } 8 | const select = opts[0] as HTMLSelectElement 9 | return select.options[select.selectedIndex].textContent 10 | }, 11 | [handle] 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /storefront/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. 6 | -------------------------------------------------------------------------------- /storefront/next-sitemap.js: -------------------------------------------------------------------------------- 1 | const excludedPaths = ["/checkout", "/account/*"] 2 | 3 | module.exports = { 4 | siteUrl: process.env.NEXT_PUBLIC_BASE_URL, 5 | generateRobotsTxt: true, 6 | exclude: excludedPaths + ["/[sitemap]"], 7 | robotsTxtOptions: { 8 | policies: [ 9 | { 10 | userAgent: "*", 11 | allow: "/", 12 | }, 13 | { 14 | userAgent: "*", 15 | disallow: excludedPaths, 16 | }, 17 | ], 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /storefront/next.config.js: -------------------------------------------------------------------------------- 1 | const checkEnvVariables = require("./check-env-variables") 2 | 3 | checkEnvVariables() 4 | 5 | /** 6 | * @type {import('next').NextConfig} 7 | */ 8 | const nextConfig = { 9 | reactStrictMode: true, 10 | eslint: { 11 | ignoreDuringBuilds: true, 12 | }, 13 | typescript: { 14 | ignoreBuildErrors: true, 15 | }, 16 | images: { 17 | remotePatterns: [ 18 | { 19 | protocol: "http", 20 | hostname: "localhost", 21 | 22 | }, 23 | { // Note: needed to serve images from /public folder 24 | protocol: process.env.NEXT_PUBLIC_BASE_URL?.startsWith('https') ? 'https' : 'http', 25 | hostname: process.env.NEXT_PUBLIC_BASE_URL?.replace(/^https?:\/\//, ''), 26 | }, 27 | { // Note: only needed when using local-file for product media 28 | protocol: "https", 29 | hostname: process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL?.replace('https://', ''), 30 | }, 31 | { // Note: can be removed after deleting demo products 32 | protocol: "https", 33 | hostname: "medusa-public-images.s3.eu-west-1.amazonaws.com", 34 | }, 35 | { // Note: can be removed after deleting demo products 36 | protocol: "https", 37 | hostname: "medusa-server-testing.s3.amazonaws.com", 38 | }, 39 | { // Note: can be removed after deleting demo products 40 | protocol: "https", 41 | hostname: "medusa-server-testing.s3.us-east-1.amazonaws.com", 42 | }, 43 | ...(process.env.NEXT_PUBLIC_MINIO_ENDPOINT ? [{ // Note: needed when using MinIO bucket storage for media 44 | protocol: "https", 45 | hostname: process.env.NEXT_PUBLIC_MINIO_ENDPOINT, 46 | }] : []), 47 | ], 48 | }, 49 | serverRuntimeConfig: { 50 | port: process.env.PORT || 3000 51 | } 52 | } 53 | 54 | module.exports = nextConfig 55 | -------------------------------------------------------------------------------- /storefront/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /storefront/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rpuls/medusajs-2.0-for-railway-boilerplate/3c5ab71afed6e7bab648036042e1e5bf7364de99/storefront/public/favicon.ico -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(checkout)/checkout/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | import { notFound } from "next/navigation" 3 | 4 | import Wrapper from "@modules/checkout/components/payment-wrapper" 5 | import CheckoutForm from "@modules/checkout/templates/checkout-form" 6 | import CheckoutSummary from "@modules/checkout/templates/checkout-summary" 7 | import { enrichLineItems, retrieveCart } from "@lib/data/cart" 8 | import { HttpTypes } from "@medusajs/types" 9 | import { getCustomer } from "@lib/data/customer" 10 | 11 | export const metadata: Metadata = { 12 | title: "Checkout", 13 | } 14 | 15 | const fetchCart = async () => { 16 | const cart = await retrieveCart() 17 | if (!cart) { 18 | return notFound() 19 | } 20 | 21 | if (cart?.items?.length) { 22 | const enrichedItems = await enrichLineItems(cart?.items, cart?.region_id!) 23 | cart.items = enrichedItems as HttpTypes.StoreCartLineItem[] 24 | } 25 | 26 | return cart 27 | } 28 | 29 | export default async function Checkout() { 30 | const cart = await fetchCart() 31 | const customer = await getCustomer() 32 | 33 | return ( 34 |
35 | 36 | 37 | 38 | 39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(checkout)/layout.tsx: -------------------------------------------------------------------------------- 1 | import LocalizedClientLink from "@modules/common/components/localized-client-link" 2 | import ChevronDown from "@modules/common/icons/chevron-down" 3 | import MedusaCTA from "@modules/layout/components/medusa-cta" 4 | 5 | export default function CheckoutLayout({ 6 | children, 7 | }: { 8 | children: React.ReactNode 9 | }) { 10 | return ( 11 |
12 |
13 | 36 |
37 |
{children}
38 |
39 | 40 |
41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(checkout)/not-found.tsx: -------------------------------------------------------------------------------- 1 | import InteractiveLink from "@modules/common/components/interactive-link" 2 | import { Metadata } from "next" 3 | 4 | export const metadata: Metadata = { 5 | title: "404", 6 | description: "Something went wrong", 7 | } 8 | 9 | export default async function NotFound() { 10 | return ( 11 |
12 |

Page not found

13 |

14 | The page you tried to access does not exist. 15 |

16 | Go to frontpage 17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(main)/account/@dashboard/addresses/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | import { notFound } from "next/navigation" 3 | 4 | import AddressBook from "@modules/account/components/address-book" 5 | 6 | import { headers } from "next/headers" 7 | import { getRegion } from "@lib/data/regions" 8 | import { getCustomer } from "@lib/data/customer" 9 | 10 | export const metadata: Metadata = { 11 | title: "Addresses", 12 | description: "View your addresses", 13 | } 14 | 15 | export default async function Addresses({ 16 | params, 17 | }: { 18 | params: { countryCode: string } 19 | }) { 20 | const { countryCode } = params 21 | const customer = await getCustomer() 22 | const region = await getRegion(countryCode) 23 | 24 | if (!customer || !region) { 25 | notFound() 26 | } 27 | 28 | return ( 29 |
30 |
31 |

Shipping Addresses

32 |

33 | View and update your shipping addresses, you can add as many as you 34 | like. Saving your addresses will make them available during checkout. 35 |

36 |
37 | 38 |
39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(main)/account/@dashboard/loading.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from "@modules/common/icons/spinner" 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(main)/account/@dashboard/orders/details/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | import { notFound } from "next/navigation" 3 | 4 | import OrderDetailsTemplate from "@modules/order/templates/order-details-template" 5 | import { retrieveOrder } from "@lib/data/orders" 6 | import { enrichLineItems } from "@lib/data/cart" 7 | import { HttpTypes } from "@medusajs/types" 8 | 9 | type Props = { 10 | params: { id: string } 11 | } 12 | 13 | async function getOrder(id: string) { 14 | const order = await retrieveOrder(id) 15 | 16 | if (!order) { 17 | return 18 | } 19 | 20 | const enrichedItems = await enrichLineItems(order.items, order.region_id!) 21 | 22 | return { 23 | ...order, 24 | items: enrichedItems, 25 | } as unknown as HttpTypes.StoreOrder 26 | } 27 | 28 | export async function generateMetadata({ params }: Props): Promise { 29 | const order = await getOrder(params.id).catch(() => null) 30 | 31 | if (!order) { 32 | notFound() 33 | } 34 | 35 | return { 36 | title: `Order #${order.display_id}`, 37 | description: `View your order`, 38 | } 39 | } 40 | 41 | export default async function OrderDetailPage({ params }: Props) { 42 | const order = await getOrder(params.id).catch(() => null) 43 | 44 | if (!order) { 45 | notFound() 46 | } 47 | 48 | return 49 | } 50 | -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(main)/account/@dashboard/orders/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | 3 | import OrderOverview from "@modules/account/components/order-overview" 4 | import { notFound } from "next/navigation" 5 | import { listOrders } from "@lib/data/orders" 6 | 7 | export const metadata: Metadata = { 8 | title: "Orders", 9 | description: "Overview of your previous orders.", 10 | } 11 | 12 | export default async function Orders() { 13 | const orders = await listOrders() 14 | 15 | if (!orders) { 16 | notFound() 17 | } 18 | 19 | return ( 20 |
21 |
22 |

Orders

23 |

24 | View your previous orders and their status. You can also create 25 | returns or exchanges for your orders if needed. 26 |

27 |
28 |
29 | 30 |
31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(main)/account/@dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | 3 | import Overview from "@modules/account/components/overview" 4 | import { notFound } from "next/navigation" 5 | import { getCustomer } from "@lib/data/customer" 6 | import { listOrders } from "@lib/data/orders" 7 | 8 | export const metadata: Metadata = { 9 | title: "Account", 10 | description: "Overview of your account activity.", 11 | } 12 | 13 | export default async function OverviewTemplate() { 14 | const customer = await getCustomer().catch(() => null) 15 | const orders = (await listOrders().catch(() => null)) || null 16 | 17 | if (!customer) { 18 | notFound() 19 | } 20 | 21 | return 22 | } 23 | -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(main)/account/@dashboard/profile/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | 3 | import ProfilePhone from "@modules/account//components/profile-phone" 4 | import ProfileBillingAddress from "@modules/account/components/profile-billing-address" 5 | import ProfileEmail from "@modules/account/components/profile-email" 6 | import ProfileName from "@modules/account/components/profile-name" 7 | import ProfilePassword from "@modules/account/components/profile-password" 8 | 9 | import { notFound } from "next/navigation" 10 | import { listRegions } from "@lib/data/regions" 11 | import { getCustomer } from "@lib/data/customer" 12 | 13 | export const metadata: Metadata = { 14 | title: "Profile", 15 | description: "View and edit your Medusa Store profile.", 16 | } 17 | 18 | export default async function Profile() { 19 | const customer = await getCustomer() 20 | const regions = await listRegions() 21 | 22 | if (!customer || !regions) { 23 | notFound() 24 | } 25 | 26 | return ( 27 |
28 |
29 |

Profile

30 |

31 | View and update your profile information, including your name, email, 32 | and phone number. You can also update your billing address, or change 33 | your password. 34 |

35 |
36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 |
48 | ) 49 | } 50 | 51 | const Divider = () => { 52 | return
53 | } 54 | -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(main)/account/@login/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | 3 | import LoginTemplate from "@modules/account/templates/login-template" 4 | 5 | export const metadata: Metadata = { 6 | title: "Sign in", 7 | description: "Sign in to your Medusa Store account.", 8 | } 9 | 10 | export default function Login() { 11 | return 12 | } 13 | -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(main)/account/layout.tsx: -------------------------------------------------------------------------------- 1 | import { getCustomer } from "@lib/data/customer" 2 | import AccountLayout from "@modules/account/templates/account-layout" 3 | 4 | export default async function AccountPageLayout({ 5 | dashboard, 6 | login, 7 | }: { 8 | dashboard?: React.ReactNode 9 | login?: React.ReactNode 10 | }) { 11 | const customer = await getCustomer().catch(() => null) 12 | 13 | return ( 14 | 15 | {customer ? dashboard : login} 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(main)/account/loading.tsx: -------------------------------------------------------------------------------- 1 | import Spinner from "@modules/common/icons/spinner" 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 | 7 |
8 | ) 9 | } 10 | -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(main)/cart/loading.tsx: -------------------------------------------------------------------------------- 1 | import SkeletonCartPage from "@modules/skeletons/templates/skeleton-cart-page" 2 | 3 | export default function Loading() { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(main)/cart/not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | 3 | import InteractiveLink from "@modules/common/components/interactive-link" 4 | 5 | export const metadata: Metadata = { 6 | title: "404", 7 | description: "Something went wrong", 8 | } 9 | 10 | export default function NotFound() { 11 | return ( 12 |
13 |

Page not found

14 |

15 | The cart you tried to access does not exist. Clear your cookies and try 16 | again. 17 |

18 | Go to frontpage 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(main)/cart/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | import CartTemplate from "@modules/cart/templates" 3 | 4 | import { enrichLineItems, retrieveCart } from "@lib/data/cart" 5 | import { HttpTypes } from "@medusajs/types" 6 | import { getCustomer } from "@lib/data/customer" 7 | 8 | export const metadata: Metadata = { 9 | title: "Cart", 10 | description: "View your cart", 11 | } 12 | 13 | const fetchCart = async () => { 14 | const cart = await retrieveCart() 15 | 16 | if (!cart) { 17 | return null 18 | } 19 | 20 | if (cart?.items?.length) { 21 | const enrichedItems = await enrichLineItems(cart?.items, cart?.region_id!) 22 | cart.items = enrichedItems as HttpTypes.StoreCartLineItem[] 23 | } 24 | 25 | return cart 26 | } 27 | 28 | export default async function Cart() { 29 | const cart = await fetchCart() 30 | const customer = await getCustomer() 31 | 32 | return 33 | } 34 | -------------------------------------------------------------------------------- /storefront/src/app/[countryCode]/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | 3 | import Footer from "@modules/layout/templates/footer" 4 | import Nav from "@modules/layout/templates/nav" 5 | import { getBaseURL } from "@lib/util/env" 6 | 7 | export const metadata: Metadata = { 8 | metadataBase: new URL(getBaseURL()), 9 | } 10 | 11 | export default async function PageLayout(props: { children: React.ReactNode }) { 12 | return ( 13 | <> 14 |