├── .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 |
14 |
19 |
20 |
21 | Back to shopping cart
22 |
23 |
24 | Back
25 |
26 |
27 |
32 | Medusa Store
33 |
34 |
35 |
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 |
15 | {props.children}
16 |
17 | >
18 | )
19 | }
20 |
--------------------------------------------------------------------------------
/storefront/src/app/[countryCode]/(main)/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 page you tried to access does not exist.
16 |
17 |
Go to frontpage
18 |
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/storefront/src/app/[countryCode]/(main)/order/confirmed/[id]/loading.tsx:
--------------------------------------------------------------------------------
1 | import SkeletonOrderConfirmed from "@modules/skeletons/templates/skeleton-order-confirmed"
2 |
3 | export default function Loading() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/storefront/src/app/[countryCode]/(main)/order/confirmed/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next"
2 |
3 | import OrderCompletedTemplate from "@modules/order/templates/order-completed-template"
4 | import { notFound } from "next/navigation"
5 | import { enrichLineItems } from "@lib/data/cart"
6 | import { retrieveOrder } from "@lib/data/orders"
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 const metadata: Metadata = {
29 | title: "Order Confirmed",
30 | description: "You purchase was successful",
31 | }
32 |
33 | export default async function OrderConfirmedPage({ params }: Props) {
34 | const order = await getOrder(params.id)
35 | if (!order) {
36 | return notFound()
37 | }
38 |
39 | return
40 | }
41 |
--------------------------------------------------------------------------------
/storefront/src/app/[countryCode]/(main)/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next"
2 |
3 | import FeaturedProducts from "@modules/home/components/featured-products"
4 | import Hero from "@modules/home/components/hero"
5 | import { getCollectionsWithProducts } from "@lib/data/collections"
6 | import { getRegion } from "@lib/data/regions"
7 |
8 | export const metadata: Metadata = {
9 | title: "Medusa Next.js Starter Template",
10 | description:
11 | "A performant frontend ecommerce starter template with Next.js 14 and Medusa.",
12 | }
13 |
14 | export default async function Home({
15 | params: { countryCode },
16 | }: {
17 | params: { countryCode: string }
18 | }) {
19 | const collections = await getCollectionsWithProducts(countryCode)
20 | const region = await getRegion(countryCode)
21 |
22 | if (!collections || !region) {
23 | return null
24 | }
25 |
26 | return (
27 | <>
28 |
29 |
34 | >
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/storefront/src/app/[countryCode]/(main)/results/[query]/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next"
2 |
3 | import SearchResultsTemplate from "@modules/search/templates/search-results-template"
4 |
5 | import { search } from "@modules/search/actions"
6 | import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
7 |
8 | export const metadata: Metadata = {
9 | title: "Search",
10 | description: "Explore all of our products.",
11 | }
12 |
13 | type Params = {
14 | params: { query: string; countryCode: string }
15 | searchParams: {
16 | sortBy?: SortOptions
17 | page?: string
18 | }
19 | }
20 |
21 | export default async function SearchResults({ params, searchParams }: Params) {
22 | const { query } = params
23 | const { sortBy, page } = searchParams
24 |
25 | const hits = await search(query).then((data) => data)
26 |
27 | const ids = hits
28 | .map((h) => h.objectID || h.id)
29 | .filter((id): id is string => {
30 | return typeof id === "string"
31 | })
32 |
33 | return (
34 |
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/storefront/src/app/[countryCode]/(main)/search/page.tsx:
--------------------------------------------------------------------------------
1 | import SearchModal from "@modules/search/templates/search-modal"
2 |
3 | export default function SearchModalRoute() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/storefront/src/app/[countryCode]/(main)/store/page.tsx:
--------------------------------------------------------------------------------
1 | import { Metadata } from "next"
2 |
3 | import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
4 | import StoreTemplate from "@modules/store/templates"
5 |
6 | export const metadata: Metadata = {
7 | title: "Store",
8 | description: "Explore all of our products.",
9 | }
10 |
11 | type Params = {
12 | searchParams: {
13 | sortBy?: SortOptions
14 | page?: string
15 | }
16 | params: {
17 | countryCode: string
18 | }
19 | }
20 |
21 | export default async function StorePage({ searchParams, params }: Params) {
22 | const { sortBy, page } = searchParams
23 |
24 | return (
25 |
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/storefront/src/app/api/healthcheck/route.ts:
--------------------------------------------------------------------------------
1 | import { NextRequest, NextResponse } from 'next/server';
2 |
3 | export const GET = (req: NextRequest) => {
4 | return NextResponse.json({ status: 'ok' });
5 | };
--------------------------------------------------------------------------------
/storefront/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { getBaseURL } from "@lib/util/env"
2 | import { Metadata } from "next"
3 | import "styles/globals.css"
4 |
5 | export const metadata: Metadata = {
6 | metadataBase: new URL(getBaseURL()),
7 | }
8 |
9 | export default function RootLayout(props: { children: React.ReactNode }) {
10 | return (
11 |
12 |
13 | {props.children}
14 |
15 |
16 | )
17 | }
18 |
--------------------------------------------------------------------------------
/storefront/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowUpRightMini } from "@medusajs/icons"
2 | import { Text } from "@medusajs/ui"
3 | import { Metadata } from "next"
4 | import Link from "next/link"
5 |
6 | export const metadata: Metadata = {
7 | title: "404",
8 | description: "Something went wrong",
9 | }
10 |
11 | export default function NotFound() {
12 | return (
13 |
14 |
Page not found
15 |
16 | The page you tried to access does not exist.
17 |
18 |
22 |
Go to frontpage
23 |
27 |
28 |
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/storefront/src/app/opengraph-image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rpuls/medusajs-2.0-for-railway-boilerplate/3c5ab71afed6e7bab648036042e1e5bf7364de99/storefront/src/app/opengraph-image.jpg
--------------------------------------------------------------------------------
/storefront/src/app/twitter-image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rpuls/medusajs-2.0-for-railway-boilerplate/3c5ab71afed6e7bab648036042e1e5bf7364de99/storefront/src/app/twitter-image.jpg
--------------------------------------------------------------------------------
/storefront/src/lib/config.ts:
--------------------------------------------------------------------------------
1 | import Medusa from "@medusajs/js-sdk"
2 |
3 | // Defaults to standard port for Medusa server
4 | let MEDUSA_BACKEND_URL = "http://localhost:9000"
5 |
6 | if (process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL) {
7 | MEDUSA_BACKEND_URL = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL
8 | }
9 |
10 | export const sdk = new Medusa({
11 | baseUrl: MEDUSA_BACKEND_URL,
12 | debug: process.env.NODE_ENV === "development",
13 | publishableKey: process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY,
14 | })
15 |
--------------------------------------------------------------------------------
/storefront/src/lib/constants.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import { CreditCard } from "@medusajs/icons"
3 |
4 | import Ideal from "@modules/common/icons/ideal"
5 | import Bancontact from "@modules/common/icons/bancontact"
6 | import PayPal from "@modules/common/icons/paypal"
7 |
8 | /* Map of payment provider_id to their title and icon. Add in any payment providers you want to use. */
9 | export const paymentInfoMap: Record<
10 | string,
11 | { title: string; icon: React.JSX.Element }
12 | > = {
13 | pp_stripe_stripe: {
14 | title: "Credit card",
15 | icon: ,
16 | },
17 | "pp_stripe-ideal_stripe": {
18 | title: "iDeal",
19 | icon: ,
20 | },
21 | "pp_stripe-bancontact_stripe": {
22 | title: "Bancontact",
23 | icon: ,
24 | },
25 | pp_paypal_paypal: {
26 | title: "PayPal",
27 | icon: ,
28 | },
29 | pp_system_default: {
30 | title: "Manual Payment",
31 | icon: ,
32 | },
33 | // Add more payment providers here
34 | }
35 |
36 | // This only checks if it is native stripe for card payments, it ignores the other stripe-based providers
37 | export const isStripe = (providerId?: string) => {
38 | return providerId?.startsWith("pp_stripe_")
39 | }
40 | export const isPaypal = (providerId?: string) => {
41 | return providerId?.startsWith("pp_paypal")
42 | }
43 | export const isManual = (providerId?: string) => {
44 | return providerId?.startsWith("pp_system_default")
45 | }
46 |
47 | // Add currencies that don't need to be divided by 100
48 | export const noDivisionCurrencies = [
49 | "krw",
50 | "jpy",
51 | "vnd",
52 | "clp",
53 | "pyg",
54 | "xaf",
55 | "xof",
56 | "bif",
57 | "djf",
58 | "gnf",
59 | "kmf",
60 | "mga",
61 | "rwf",
62 | "xpf",
63 | "htg",
64 | "vuv",
65 | "xag",
66 | "xdr",
67 | "xau",
68 | ]
69 |
--------------------------------------------------------------------------------
/storefront/src/lib/context/modal-context.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import React, { createContext, useContext } from "react"
4 |
5 | interface ModalContext {
6 | close: () => void
7 | }
8 |
9 | const ModalContext = createContext(null)
10 |
11 | interface ModalProviderProps {
12 | children?: React.ReactNode
13 | close: () => void
14 | }
15 |
16 | export const ModalProvider = ({ children, close }: ModalProviderProps) => {
17 | return (
18 |
23 | {children}
24 |
25 | )
26 | }
27 |
28 | export const useModal = () => {
29 | const context = useContext(ModalContext)
30 | if (context === null) {
31 | throw new Error("useModal must be used within a ModalProvider")
32 | }
33 | return context
34 | }
35 |
--------------------------------------------------------------------------------
/storefront/src/lib/data/categories.ts:
--------------------------------------------------------------------------------
1 | import { sdk } from "@lib/config"
2 | import { cache } from "react"
3 |
4 | export const listCategories = cache(async function () {
5 | return sdk.store.category
6 | .list({ fields: "+category_children" }, { next: { tags: ["categories"] } })
7 | .then(({ product_categories }) => product_categories)
8 | })
9 |
10 | export const getCategoriesList = cache(async function (
11 | offset: number = 0,
12 | limit: number = 100
13 | ) {
14 | return sdk.store.category.list(
15 | // TODO: Look into fixing the type
16 | // @ts-ignore
17 | { limit, offset },
18 | { next: { tags: ["categories"] } }
19 | )
20 | })
21 |
22 | export const getCategoryByHandle = cache(async function (
23 | categoryHandle: string[]
24 | ) {
25 |
26 | return sdk.store.category.list(
27 | // TODO: Look into fixing the type
28 | // @ts-ignore
29 | { handle: categoryHandle },
30 | { next: { tags: ["categories"] } }
31 | )
32 | })
33 |
--------------------------------------------------------------------------------
/storefront/src/lib/data/cookies.ts:
--------------------------------------------------------------------------------
1 | import "server-only"
2 | import { cookies } from "next/headers"
3 |
4 | export const getAuthHeaders = (): { authorization: string } | {} => {
5 | const token = cookies().get("_medusa_jwt")?.value
6 |
7 | if (token) {
8 | return { authorization: `Bearer ${token}` }
9 | }
10 |
11 | return {}
12 | }
13 |
14 | export const setAuthToken = (token: string) => {
15 | cookies().set("_medusa_jwt", token, {
16 | maxAge: 60 * 60 * 24 * 7,
17 | httpOnly: true,
18 | sameSite: "strict",
19 | secure: process.env.NODE_ENV === "production",
20 | })
21 | }
22 |
23 | export const removeAuthToken = () => {
24 | cookies().set("_medusa_jwt", "", {
25 | maxAge: -1,
26 | })
27 | }
28 |
29 | export const getCartId = () => {
30 | return cookies().get("_medusa_cart_id")?.value
31 | }
32 |
33 | export const setCartId = (cartId: string) => {
34 | cookies().set("_medusa_cart_id", cartId, {
35 | maxAge: 60 * 60 * 24 * 7,
36 | httpOnly: true,
37 | sameSite: "strict",
38 | secure: process.env.NODE_ENV === "production",
39 | })
40 | }
41 |
42 | export const removeCartId = () => {
43 | cookies().set("_medusa_cart_id", "", { maxAge: -1 })
44 | }
45 |
--------------------------------------------------------------------------------
/storefront/src/lib/data/fulfillment.ts:
--------------------------------------------------------------------------------
1 | import { sdk } from "@lib/config"
2 | import { cache } from "react"
3 |
4 | // Shipping actions
5 | export const listCartShippingMethods = cache(async function (cartId: string) {
6 | return sdk.store.fulfillment
7 | .listCartOptions({ cart_id: cartId }, { next: { tags: ["shipping"] } })
8 | .then(({ shipping_options }) => shipping_options)
9 | .catch(() => {
10 | return null
11 | })
12 | })
13 |
--------------------------------------------------------------------------------
/storefront/src/lib/data/onboarding.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 | import { cookies } from "next/headers"
3 | import { redirect } from "next/navigation"
4 |
5 | export async function resetOnboardingState(orderId: string) {
6 | cookies().set("_medusa_onboarding", "false", { maxAge: -1 })
7 | redirect(`http://localhost:7001/a/orders/${orderId}`)
8 | }
9 |
--------------------------------------------------------------------------------
/storefront/src/lib/data/orders.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { sdk } from "@lib/config"
4 | import medusaError from "@lib/util/medusa-error"
5 | import { cache } from "react"
6 | import { getAuthHeaders } from "./cookies"
7 |
8 | export const retrieveOrder = cache(async function (id: string) {
9 | return sdk.store.order
10 | .retrieve(
11 | id,
12 | { fields: "*payment_collections.payments" },
13 | { next: { tags: ["order"] }, ...getAuthHeaders() }
14 | )
15 | .then(({ order }) => order)
16 | .catch((err) => medusaError(err))
17 | })
18 |
19 | export const listOrders = cache(async function (
20 | limit: number = 10,
21 | offset: number = 0
22 | ) {
23 | return sdk.store.order
24 | .list({ limit, offset }, { next: { tags: ["order"] }, ...getAuthHeaders() })
25 | .then(({ orders }) => orders)
26 | .catch((err) => medusaError(err))
27 | })
28 |
--------------------------------------------------------------------------------
/storefront/src/lib/data/payment.ts:
--------------------------------------------------------------------------------
1 | import { sdk } from "@lib/config"
2 | import { cache } from "react"
3 |
4 | // Shipping actions
5 | export const listCartPaymentMethods = cache(async function (regionId: string) {
6 | return sdk.store.payment
7 | .listPaymentProviders(
8 | { region_id: regionId },
9 | { next: { tags: ["payment_providers"] } }
10 | )
11 | .then(({ payment_providers }) => payment_providers)
12 | .catch(() => {
13 | return null
14 | })
15 | })
16 |
--------------------------------------------------------------------------------
/storefront/src/lib/data/regions.ts:
--------------------------------------------------------------------------------
1 | import { sdk } from "@lib/config"
2 | import medusaError from "@lib/util/medusa-error"
3 | import { cache } from "react"
4 | import { HttpTypes } from "@medusajs/types"
5 |
6 | export const listRegions = cache(async function () {
7 | return sdk.store.region
8 | .list({}, { next: { tags: ["regions"] } })
9 | .then(({ regions }) => regions)
10 | .catch(medusaError)
11 | })
12 |
13 | export const retrieveRegion = cache(async function (id: string) {
14 | return sdk.store.region
15 | .retrieve(id, {}, { next: { tags: ["regions"] } })
16 | .then(({ region }) => region)
17 | .catch(medusaError)
18 | })
19 |
20 | const regionMap = new Map()
21 |
22 | export const getRegion = cache(async function (countryCode: string) {
23 | try {
24 | if (regionMap.has(countryCode)) {
25 | return regionMap.get(countryCode)
26 | }
27 |
28 | const regions = await listRegions()
29 |
30 | if (!regions) {
31 | return null
32 | }
33 |
34 | regions.forEach((region) => {
35 | region.countries?.forEach((c) => {
36 | regionMap.set(c?.iso_2 ?? "", region)
37 | })
38 | })
39 |
40 | const region = countryCode
41 | ? regionMap.get(countryCode)
42 | : regionMap.get("us")
43 |
44 | return region
45 | } catch (e: any) {
46 | return null
47 | }
48 | })
49 |
--------------------------------------------------------------------------------
/storefront/src/lib/hooks/use-in-view.tsx:
--------------------------------------------------------------------------------
1 | import { RefObject, useEffect, useState } from "react"
2 |
3 | export const useIntersection = (
4 | element: RefObject,
5 | rootMargin: string
6 | ) => {
7 | const [isVisible, setState] = useState(false)
8 |
9 | useEffect(() => {
10 | if (!element.current) {
11 | return
12 | }
13 |
14 | const el = element.current
15 |
16 | const observer = new IntersectionObserver(
17 | ([entry]) => {
18 | setState(entry.isIntersecting)
19 | },
20 | { rootMargin }
21 | )
22 |
23 | observer.observe(el)
24 |
25 | return () => observer.unobserve(el)
26 | }, [element, rootMargin])
27 |
28 | return isVisible
29 | }
30 |
--------------------------------------------------------------------------------
/storefront/src/lib/hooks/use-toggle-state.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react"
2 |
3 | export type StateType = [boolean, () => void, () => void, () => void] & {
4 | state: boolean
5 | open: () => void
6 | close: () => void
7 | toggle: () => void
8 | }
9 |
10 | /**
11 | *
12 | * @param initialState - boolean
13 | * @returns An array like object with `state`, `open`, `close`, and `toggle` properties
14 | * to allow both object and array destructuring
15 | *
16 | * ```
17 | * const [showModal, openModal, closeModal, toggleModal] = useToggleState()
18 | * // or
19 | * const { state, open, close, toggle } = useToggleState()
20 | * ```
21 | */
22 |
23 | const useToggleState = (initialState = false) => {
24 | const [state, setState] = useState(initialState)
25 |
26 | const close = () => {
27 | setState(false)
28 | }
29 |
30 | const open = () => {
31 | setState(true)
32 | }
33 |
34 | const toggle = () => {
35 | setState((state) => !state)
36 | }
37 |
38 | const hookData = [state, open, close, toggle] as StateType
39 | hookData.state = state
40 | hookData.open = open
41 | hookData.close = close
42 | hookData.toggle = toggle
43 | return hookData
44 | }
45 |
46 | export default useToggleState
47 |
--------------------------------------------------------------------------------
/storefront/src/lib/search-client.ts:
--------------------------------------------------------------------------------
1 | import { instantMeiliSearch } from "@meilisearch/instant-meilisearch"
2 |
3 | const endpoint =
4 | process.env.NEXT_PUBLIC_SEARCH_ENDPOINT || "http://127.0.0.1:7700"
5 |
6 | const apiKey = process.env.NEXT_PUBLIC_SEARCH_API_KEY || "test_key"
7 |
8 | export const searchClient = instantMeiliSearch(endpoint, apiKey)
9 |
10 | export const SEARCH_INDEX_NAME =
11 | process.env.NEXT_PUBLIC_INDEX_NAME || "products"
12 |
13 | // If you want to use Algolia instead then uncomment the following lines, and delete the above lines
14 | // you should also install algoliasearch - yarn add algoliasearch
15 |
16 | // import algoliasearch from "algoliasearch/lite"
17 |
18 | // const appId = process.env.NEXT_PUBLIC_SEARCH_APP_ID || "test_app_id"
19 |
20 | // const apiKey = process.env.NEXT_PUBLIC_SEARCH_API_KEY || "test_key"
21 |
22 | // export const searchClient = algoliasearch(appId, apiKey)
23 |
24 | // export const SEARCH_INDEX_NAME =
25 | // process.env.NEXT_PUBLIC_INDEX_NAME || "products"
26 |
--------------------------------------------------------------------------------
/storefront/src/lib/util/compare-addresses.ts:
--------------------------------------------------------------------------------
1 | import { isEqual, pick } from "lodash"
2 |
3 | export default function compareAddresses(address1: any, address2: any) {
4 | return isEqual(
5 | pick(address1, [
6 | "first_name",
7 | "last_name",
8 | "address_1",
9 | "company",
10 | "postal_code",
11 | "city",
12 | "country_code",
13 | "province",
14 | "phone",
15 | ]),
16 | pick(address2, [
17 | "first_name",
18 | "last_name",
19 | "address_1",
20 | "company",
21 | "postal_code",
22 | "city",
23 | "country_code",
24 | "province",
25 | "phone",
26 | ])
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/storefront/src/lib/util/env.ts:
--------------------------------------------------------------------------------
1 | export const getBaseURL = () => {
2 | return process.env.NEXT_PUBLIC_BASE_URL || "https://localhost:8000"
3 | }
4 |
--------------------------------------------------------------------------------
/storefront/src/lib/util/get-precentage-diff.ts:
--------------------------------------------------------------------------------
1 | export const getPercentageDiff = (original: number, calculated: number) => {
2 | const diff = original - calculated
3 | const decrease = (diff / original) * 100
4 |
5 | return decrease.toFixed()
6 | }
7 |
--------------------------------------------------------------------------------
/storefront/src/lib/util/isEmpty.ts:
--------------------------------------------------------------------------------
1 | export const isObject = (input: any) => input instanceof Object
2 | export const isArray = (input: any) => Array.isArray(input)
3 | export const isEmpty = (input: any) => {
4 | return (
5 | input === null ||
6 | input === undefined ||
7 | (isObject(input) && Object.keys(input).length === 0) ||
8 | (isArray(input) && (input as any[]).length === 0) ||
9 | (typeof input === "string" && input.trim().length === 0)
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/storefront/src/lib/util/medusa-error.ts:
--------------------------------------------------------------------------------
1 | export default function medusaError(error: any): never {
2 | if (error.response) {
3 | // The request was made and the server responded with a status code
4 | // that falls out of the range of 2xx
5 | const u = new URL(error.config.url, error.config.baseURL)
6 | console.error("Resource:", u.toString())
7 | console.error("Response data:", error.response.data)
8 | console.error("Status code:", error.response.status)
9 | console.error("Headers:", error.response.headers)
10 |
11 | // Extracting the error message from the response data
12 | const message = error.response.data.message || error.response.data
13 |
14 | throw new Error(message.charAt(0).toUpperCase() + message.slice(1) + ".")
15 | } else if (error.request) {
16 | // The request was made but no response was received
17 | throw new Error("No response received: " + error.request)
18 | } else {
19 | // Something happened in setting up the request that triggered an Error
20 | throw new Error("Error setting up the request: " + error.message)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/storefront/src/lib/util/money.ts:
--------------------------------------------------------------------------------
1 | import { isEmpty } from "./isEmpty"
2 |
3 | type ConvertToLocaleParams = {
4 | amount: number
5 | currency_code: string
6 | minimumFractionDigits?: number
7 | maximumFractionDigits?: number
8 | locale?: string
9 | }
10 |
11 | export const convertToLocale = ({
12 | amount,
13 | currency_code,
14 | minimumFractionDigits,
15 | maximumFractionDigits,
16 | locale = "en-US",
17 | }: ConvertToLocaleParams) => {
18 | return currency_code && !isEmpty(currency_code)
19 | ? new Intl.NumberFormat(locale, {
20 | style: "currency",
21 | currency: currency_code,
22 | minimumFractionDigits,
23 | maximumFractionDigits,
24 | }).format(amount)
25 | : amount.toString()
26 | }
27 |
--------------------------------------------------------------------------------
/storefront/src/lib/util/repeat.ts:
--------------------------------------------------------------------------------
1 | const repeat = (times: number) => {
2 | return Array.from(Array(times).keys())
3 | }
4 |
5 | export default repeat
6 |
--------------------------------------------------------------------------------
/storefront/src/lib/util/sort-products.ts:
--------------------------------------------------------------------------------
1 | import { HttpTypes } from "@medusajs/types"
2 | import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
3 |
4 | interface MinPricedProduct extends HttpTypes.StoreProduct {
5 | _minPrice?: number
6 | }
7 |
8 | /**
9 | * Helper function to sort products by price until the store API supports sorting by price
10 | * @param products
11 | * @param sortBy
12 | * @returns products sorted by price
13 | */
14 | export function sortProducts(
15 | products: HttpTypes.StoreProduct[],
16 | sortBy: SortOptions
17 | ): HttpTypes.StoreProduct[] {
18 | let sortedProducts = products as MinPricedProduct[]
19 |
20 | if (["price_asc", "price_desc"].includes(sortBy)) {
21 | // Precompute the minimum price for each product
22 | sortedProducts.forEach((product) => {
23 | if (product.variants && product.variants.length > 0) {
24 | product._minPrice = Math.min(
25 | ...product.variants.map(
26 | (variant) => variant?.calculated_price?.calculated_amount || 0
27 | )
28 | )
29 | } else {
30 | product._minPrice = Infinity
31 | }
32 | })
33 |
34 | // Sort products based on the precomputed minimum prices
35 | sortedProducts.sort((a, b) => {
36 | const diff = a._minPrice! - b._minPrice!
37 | return sortBy === "price_asc" ? diff : -diff
38 | })
39 | }
40 |
41 | if (sortBy === "created_at") {
42 | sortedProducts.sort((a, b) => {
43 | return (
44 | new Date(b.created_at!).getTime() - new Date(a.created_at!).getTime()
45 | )
46 | })
47 | }
48 |
49 | return sortedProducts
50 | }
51 |
--------------------------------------------------------------------------------
/storefront/src/modules/account/components/address-book/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import AddAddress from "../address-card/add-address"
4 | import EditAddress from "../address-card/edit-address-modal"
5 | import { HttpTypes } from "@medusajs/types"
6 |
7 | type AddressBookProps = {
8 | customer: HttpTypes.StoreCustomer
9 | region: HttpTypes.StoreRegion
10 | }
11 |
12 | const AddressBook: React.FC = ({ customer, region }) => {
13 | return (
14 |
15 |
16 |
17 | {customer.addresses.map((address) => {
18 | return (
19 |
20 | )
21 | })}
22 |
23 |
24 | )
25 | }
26 |
27 | export default AddressBook
28 |
--------------------------------------------------------------------------------
/storefront/src/modules/account/components/order-overview/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Button } from "@medusajs/ui"
4 |
5 | import OrderCard from "../order-card"
6 | import LocalizedClientLink from "@modules/common/components/localized-client-link"
7 | import { HttpTypes } from "@medusajs/types"
8 |
9 | const OrderOverview = ({ orders }: { orders: HttpTypes.StoreOrder[] }) => {
10 | if (orders?.length) {
11 | return (
12 |
13 | {orders.map((o) => (
14 |
18 |
19 |
20 | ))}
21 |
22 | )
23 | }
24 |
25 | return (
26 |
30 |
Nothing to see here
31 |
32 | You don't have any orders yet, let us change that {":)"}
33 |
34 |
35 |
36 |
37 | Continue shopping
38 |
39 |
40 |
41 |
42 | )
43 | }
44 |
45 | export default OrderOverview
46 |
--------------------------------------------------------------------------------
/storefront/src/modules/account/templates/account-layout.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import UnderlineLink from "@modules/common/components/interactive-link"
4 |
5 | import AccountNav from "../components/account-nav"
6 | import { HttpTypes } from "@medusajs/types"
7 |
8 | interface AccountLayoutProps {
9 | customer: HttpTypes.StoreCustomer | null
10 | children: React.ReactNode
11 | }
12 |
13 | const AccountLayout: React.FC = ({
14 | customer,
15 | children,
16 | }) => {
17 | return (
18 |
19 |
20 |
21 |
22 |
{children}
23 |
24 |
25 |
26 |
Got questions?
27 |
28 | You can find frequently asked questions and answers on our
29 | customer service page.
30 |
31 |
32 |
33 |
34 | Customer Service
35 |
36 |
37 |
38 |
39 |
40 | )
41 | }
42 |
43 | export default AccountLayout
44 |
--------------------------------------------------------------------------------
/storefront/src/modules/account/templates/login-template.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useState } from "react"
4 |
5 | import Register from "@modules/account/components/register"
6 | import Login from "@modules/account/components/login"
7 |
8 | export enum LOGIN_VIEW {
9 | SIGN_IN = "sign-in",
10 | REGISTER = "register",
11 | }
12 |
13 | const LoginTemplate = () => {
14 | const [currentView, setCurrentView] = useState("sign-in")
15 |
16 | return (
17 |
18 | {currentView === "sign-in" ? (
19 |
20 | ) : (
21 |
22 | )}
23 |
24 | )
25 | }
26 |
27 | export default LoginTemplate
28 |
--------------------------------------------------------------------------------
/storefront/src/modules/cart/components/empty-cart-message/index.tsx:
--------------------------------------------------------------------------------
1 | import { Heading, Text } from "@medusajs/ui"
2 |
3 | import InteractiveLink from "@modules/common/components/interactive-link"
4 |
5 | const EmptyCartMessage = () => {
6 | return (
7 |
8 |
12 | Cart
13 |
14 |
15 | You don't have anything in your cart. Let's change that, use
16 | the link below to start browsing our products.
17 |
18 |
19 | Explore products
20 |
21 |
22 | )
23 | }
24 |
25 | export default EmptyCartMessage
26 |
--------------------------------------------------------------------------------
/storefront/src/modules/cart/components/sign-in-prompt/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Heading, Text } from "@medusajs/ui"
2 | import LocalizedClientLink from "@modules/common/components/localized-client-link"
3 |
4 | const SignInPrompt = () => {
5 | return (
6 |
7 |
8 |
9 | Already have an account?
10 |
11 |
12 | Sign in for a better experience.
13 |
14 |
15 |
16 |
17 |
18 | Sign in
19 |
20 |
21 |
22 |
23 | )
24 | }
25 |
26 | export default SignInPrompt
27 |
--------------------------------------------------------------------------------
/storefront/src/modules/cart/templates/index.tsx:
--------------------------------------------------------------------------------
1 | import ItemsTemplate from "./items"
2 | import Summary from "./summary"
3 | import EmptyCartMessage from "../components/empty-cart-message"
4 | import SignInPrompt from "../components/sign-in-prompt"
5 | import Divider from "@modules/common/components/divider"
6 | import { HttpTypes } from "@medusajs/types"
7 |
8 | const CartTemplate = ({
9 | cart,
10 | customer,
11 | }: {
12 | cart: HttpTypes.StoreCart | null
13 | customer: HttpTypes.StoreCustomer | null
14 | }) => {
15 | return (
16 |
17 |
18 | {cart?.items?.length ? (
19 |
20 |
21 | {!customer && (
22 | <>
23 |
24 |
25 | >
26 | )}
27 |
28 |
29 |
30 |
31 | {cart && cart.region && (
32 | <>
33 |
34 |
35 |
36 | >
37 | )}
38 |
39 |
40 |
41 | ) : (
42 |
43 |
44 |
45 | )}
46 |
47 |
48 | )
49 | }
50 |
51 | export default CartTemplate
52 |
--------------------------------------------------------------------------------
/storefront/src/modules/cart/templates/items.tsx:
--------------------------------------------------------------------------------
1 | import repeat from "@lib/util/repeat"
2 | import { HttpTypes } from "@medusajs/types"
3 | import { Heading, Table } from "@medusajs/ui"
4 |
5 | import Item from "@modules/cart/components/item"
6 | import SkeletonLineItem from "@modules/skeletons/components/skeleton-line-item"
7 |
8 | type ItemsTemplateProps = {
9 | items?: HttpTypes.StoreCartLineItem[]
10 | }
11 |
12 | const ItemsTemplate = ({ items }: ItemsTemplateProps) => {
13 | return (
14 |
15 |
16 | Cart
17 |
18 |
19 |
20 |
21 | Item
22 |
23 | Quantity
24 |
25 | Price
26 |
27 |
28 | Total
29 |
30 |
31 |
32 |
33 | {items
34 | ? items
35 | .sort((a, b) => {
36 | return (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1
37 | })
38 | .map((item) => {
39 | return
40 | })
41 | : repeat(5).map((i) => {
42 | return
43 | })}
44 |
45 |
46 |
47 | )
48 | }
49 |
50 | export default ItemsTemplate
51 |
--------------------------------------------------------------------------------
/storefront/src/modules/cart/templates/preview.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import repeat from "@lib/util/repeat"
4 | import { HttpTypes } from "@medusajs/types"
5 | import { Table, clx } from "@medusajs/ui"
6 |
7 | import Item from "@modules/cart/components/item"
8 | import SkeletonLineItem from "@modules/skeletons/components/skeleton-line-item"
9 |
10 | type ItemsTemplateProps = {
11 | items?: HttpTypes.StoreCartLineItem[]
12 | }
13 |
14 | const ItemsPreviewTemplate = ({ items }: ItemsTemplateProps) => {
15 | const hasOverflow = items && items.length > 4
16 |
17 | return (
18 |
24 |
25 |
26 | {items
27 | ? items
28 | .sort((a, b) => {
29 | return (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1
30 | })
31 | .map((item) => {
32 | return
33 | })
34 | : repeat(5).map((i) => {
35 | return
36 | })}
37 |
38 |
39 |
40 | )
41 | }
42 |
43 | export default ItemsPreviewTemplate
44 |
--------------------------------------------------------------------------------
/storefront/src/modules/cart/templates/summary.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Button, Heading } from "@medusajs/ui"
4 |
5 | import CartTotals from "@modules/common/components/cart-totals"
6 | import Divider from "@modules/common/components/divider"
7 | import DiscountCode from "@modules/checkout/components/discount-code"
8 | import LocalizedClientLink from "@modules/common/components/localized-client-link"
9 | import { HttpTypes } from "@medusajs/types"
10 |
11 | type SummaryProps = {
12 | cart: HttpTypes.StoreCart & {
13 | promotions: HttpTypes.StorePromotion[]
14 | }
15 | }
16 |
17 | function getCheckoutStep(cart: HttpTypes.StoreCart) {
18 | if (!cart?.shipping_address?.address_1 || !cart.email) {
19 | return "address"
20 | } else if (cart?.shipping_methods?.length === 0) {
21 | return "delivery"
22 | } else {
23 | return "payment"
24 | }
25 | }
26 |
27 | const Summary = ({ cart }: SummaryProps) => {
28 | const step = getCheckoutStep(cart)
29 |
30 | return (
31 |
32 |
33 | Summary
34 |
35 |
36 |
37 |
38 |
42 | Go to checkout
43 |
44 |
45 | )
46 | }
47 |
48 | export default Summary
49 |
--------------------------------------------------------------------------------
/storefront/src/modules/checkout/components/country-select/index.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef, useImperativeHandle, useMemo, useRef } from "react"
2 |
3 | import NativeSelect, {
4 | NativeSelectProps,
5 | } from "@modules/common/components/native-select"
6 | import { HttpTypes } from "@medusajs/types"
7 |
8 | const CountrySelect = forwardRef<
9 | HTMLSelectElement,
10 | NativeSelectProps & {
11 | region?: HttpTypes.StoreRegion
12 | }
13 | >(({ placeholder = "Country", region, defaultValue, ...props }, ref) => {
14 | const innerRef = useRef(null)
15 |
16 | useImperativeHandle(
17 | ref,
18 | () => innerRef.current
19 | )
20 |
21 | const countryOptions = useMemo(() => {
22 | if (!region) {
23 | return []
24 | }
25 |
26 | return region.countries?.map((country) => ({
27 | value: country.iso_2,
28 | label: country.display_name,
29 | }))
30 | }, [region])
31 |
32 | return (
33 |
39 | {countryOptions?.map(({ value, label }, index) => (
40 |
41 | {label}
42 |
43 | ))}
44 |
45 | )
46 | })
47 |
48 | CountrySelect.displayName = "CountrySelect"
49 |
50 | export default CountrySelect
51 |
--------------------------------------------------------------------------------
/storefront/src/modules/checkout/components/error-message/index.tsx:
--------------------------------------------------------------------------------
1 | const ErrorMessage = ({ error, 'data-testid': dataTestid }: { error?: string | null, 'data-testid'?: string }) => {
2 | if (!error) {
3 | return null
4 | }
5 |
6 | return (
7 |
8 | {error}
9 |
10 | )
11 | }
12 |
13 | export default ErrorMessage
14 |
--------------------------------------------------------------------------------
/storefront/src/modules/checkout/components/payment-test/index.tsx:
--------------------------------------------------------------------------------
1 | import { Badge } from "@medusajs/ui"
2 |
3 | const PaymentTest = ({ className }: { className?: string }) => {
4 | return (
5 |
6 | Attention: For testing purposes
7 | only.
8 |
9 | )
10 | }
11 |
12 | export default PaymentTest
13 |
--------------------------------------------------------------------------------
/storefront/src/modules/checkout/components/payment-wrapper/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { loadStripe } from "@stripe/stripe-js"
4 | import React from "react"
5 | import StripeWrapper from "./stripe-wrapper"
6 | import { PayPalScriptProvider } from "@paypal/react-paypal-js"
7 | import { createContext } from "react"
8 | import { HttpTypes } from "@medusajs/types"
9 | import { isPaypal, isStripe } from "@lib/constants"
10 |
11 | type WrapperProps = {
12 | cart: HttpTypes.StoreCart
13 | children: React.ReactNode
14 | }
15 |
16 | export const StripeContext = createContext(false)
17 |
18 | const stripeKey = process.env.NEXT_PUBLIC_STRIPE_KEY
19 | const stripePromise = stripeKey ? loadStripe(stripeKey) : null
20 |
21 | const paypalClientId = process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID
22 |
23 | const Wrapper: React.FC = ({ cart, children }) => {
24 | const paymentSession = cart.payment_collection?.payment_sessions?.find(
25 | (s) => s.status === "pending"
26 | )
27 |
28 | if (
29 | isStripe(paymentSession?.provider_id) &&
30 | paymentSession &&
31 | stripePromise
32 | ) {
33 | return (
34 |
35 |
40 | {children}
41 |
42 |
43 | )
44 | }
45 |
46 | if (
47 | isPaypal(paymentSession?.provider_id) &&
48 | paypalClientId !== undefined &&
49 | cart
50 | ) {
51 | return (
52 |
60 | {children}
61 |
62 | )
63 | }
64 |
65 | return {children}
66 | }
67 |
68 | export default Wrapper
69 |
--------------------------------------------------------------------------------
/storefront/src/modules/checkout/components/payment-wrapper/stripe-wrapper.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Stripe, StripeElementsOptions } from "@stripe/stripe-js"
4 | import { Elements } from "@stripe/react-stripe-js"
5 | import { HttpTypes } from "@medusajs/types"
6 |
7 | type StripeWrapperProps = {
8 | paymentSession: HttpTypes.StorePaymentSession
9 | stripeKey?: string
10 | stripePromise: Promise | null
11 | children: React.ReactNode
12 | }
13 |
14 | const StripeWrapper: React.FC = ({
15 | paymentSession,
16 | stripeKey,
17 | stripePromise,
18 | children,
19 | }) => {
20 | const options: StripeElementsOptions = {
21 | clientSecret: paymentSession!.data?.client_secret as string | undefined,
22 | }
23 |
24 | if (!stripeKey) {
25 | throw new Error(
26 | "Stripe key is missing. Set NEXT_PUBLIC_STRIPE_KEY environment variable."
27 | )
28 | }
29 |
30 | if (!stripePromise) {
31 | throw new Error(
32 | "Stripe promise is missing. Make sure you have provided a valid Stripe key."
33 | )
34 | }
35 |
36 | if (!paymentSession?.data?.client_secret) {
37 | throw new Error(
38 | "Stripe client secret is missing. Cannot initialize Stripe."
39 | )
40 | }
41 |
42 | return (
43 |
44 | {children}
45 |
46 | )
47 | }
48 |
49 | export default StripeWrapper
50 |
--------------------------------------------------------------------------------
/storefront/src/modules/checkout/components/review/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Heading, Text, clx } from "@medusajs/ui"
4 |
5 | import PaymentButton from "../payment-button"
6 | import { useSearchParams } from "next/navigation"
7 |
8 | const Review = ({ cart }: { cart: any }) => {
9 | const searchParams = useSearchParams()
10 |
11 | const isOpen = searchParams.get("step") === "review"
12 |
13 | const paidByGiftcard =
14 | cart?.gift_cards && cart?.gift_cards?.length > 0 && cart?.total === 0
15 |
16 | const previousStepsCompleted =
17 | cart.shipping_address &&
18 | cart.shipping_methods.length > 0 &&
19 | (cart.payment_collection || paidByGiftcard)
20 |
21 | return (
22 |
23 |
24 |
33 | Review
34 |
35 |
36 | {isOpen && previousStepsCompleted && (
37 | <>
38 |
39 |
40 |
41 | By clicking the Place Order button, you confirm that you have
42 | read, understand and accept our Terms of Use, Terms of Sale and
43 | Returns Policy and acknowledge that you have read Medusa
44 | Store's Privacy Policy.
45 |
46 |
47 |
48 |
49 | >
50 | )}
51 |
52 | )
53 | }
54 |
55 | export default Review
56 |
--------------------------------------------------------------------------------
/storefront/src/modules/checkout/components/submit-button/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { Button } from "@medusajs/ui"
4 | import React from "react"
5 | import { useFormStatus } from "react-dom"
6 |
7 | export function SubmitButton({
8 | children,
9 | variant = "primary",
10 | className,
11 | "data-testid": dataTestId,
12 | }: {
13 | children: React.ReactNode
14 | variant?: "primary" | "secondary" | "transparent" | "danger" | null
15 | className?: string
16 | "data-testid"?: string
17 | }) {
18 | const { pending } = useFormStatus()
19 |
20 | return (
21 |
29 | {children}
30 |
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/storefront/src/modules/checkout/templates/checkout-form/index.tsx:
--------------------------------------------------------------------------------
1 | import { listCartShippingMethods } from "@lib/data/fulfillment"
2 | import { listCartPaymentMethods } from "@lib/data/payment"
3 | import { HttpTypes } from "@medusajs/types"
4 | import Addresses from "@modules/checkout/components/addresses"
5 | import Payment from "@modules/checkout/components/payment"
6 | import Review from "@modules/checkout/components/review"
7 | import Shipping from "@modules/checkout/components/shipping"
8 |
9 | export default async function CheckoutForm({
10 | cart,
11 | customer,
12 | }: {
13 | cart: HttpTypes.StoreCart | null
14 | customer: HttpTypes.StoreCustomer | null
15 | }) {
16 | if (!cart) {
17 | return null
18 | }
19 |
20 | const shippingMethods = await listCartShippingMethods(cart.id)
21 | const paymentMethods = await listCartPaymentMethods(cart.region?.id ?? "")
22 |
23 | if (!shippingMethods || !paymentMethods) {
24 | return null
25 | }
26 |
27 | return (
28 |
29 |
30 |
33 |
34 |
35 |
36 |
37 |
38 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | )
48 | }
49 |
--------------------------------------------------------------------------------
/storefront/src/modules/checkout/templates/checkout-summary/index.tsx:
--------------------------------------------------------------------------------
1 | import { Heading } from "@medusajs/ui"
2 |
3 | import ItemsPreviewTemplate from "@modules/cart/templates/preview"
4 | import DiscountCode from "@modules/checkout/components/discount-code"
5 | import CartTotals from "@modules/common/components/cart-totals"
6 | import Divider from "@modules/common/components/divider"
7 |
8 | const CheckoutSummary = ({ cart }: { cart: any }) => {
9 | return (
10 |
11 |
12 |
13 |
17 | In your Cart
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | )
28 | }
29 |
30 | export default CheckoutSummary
31 |
--------------------------------------------------------------------------------
/storefront/src/modules/collections/templates/index.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react"
2 |
3 | import SkeletonProductGrid from "@modules/skeletons/templates/skeleton-product-grid"
4 | import RefinementList from "@modules/store/components/refinement-list"
5 | import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
6 | import PaginatedProducts from "@modules/store/templates/paginated-products"
7 | import { HttpTypes } from "@medusajs/types"
8 |
9 | export default function CollectionTemplate({
10 | sortBy,
11 | collection,
12 | page,
13 | countryCode,
14 | }: {
15 | sortBy?: SortOptions
16 | collection: HttpTypes.StoreCollection
17 | page?: string
18 | countryCode: string
19 | }) {
20 | const pageNumber = page ? parseInt(page) : 1
21 | const sort = sortBy || "created_at"
22 |
23 | return (
24 |
25 |
26 |
27 |
28 |
{collection.title}
29 |
30 |
}>
31 |
37 |
38 |
39 |
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/components/checkbox/index.tsx:
--------------------------------------------------------------------------------
1 | import { Checkbox, Label } from "@medusajs/ui"
2 | import React from "react"
3 |
4 | type CheckboxProps = {
5 | checked?: boolean
6 | onChange?: () => void
7 | label: string
8 | name?: string
9 | 'data-testid'?: string
10 | }
11 |
12 | const CheckboxWithLabel: React.FC = ({
13 | checked = true,
14 | onChange,
15 | label,
16 | name,
17 | 'data-testid': dataTestId
18 | }) => {
19 | return (
20 |
21 |
32 |
37 | {label}
38 |
39 |
40 | )
41 | }
42 |
43 | export default CheckboxWithLabel
44 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/components/delete-button/index.tsx:
--------------------------------------------------------------------------------
1 | import { deleteLineItem } from "@lib/data/cart"
2 | import { Spinner, Trash } from "@medusajs/icons"
3 | import { clx } from "@medusajs/ui"
4 | import { useState } from "react"
5 |
6 | const DeleteButton = ({
7 | id,
8 | children,
9 | className,
10 | }: {
11 | id: string
12 | children?: React.ReactNode
13 | className?: string
14 | }) => {
15 | const [isDeleting, setIsDeleting] = useState(false)
16 |
17 | const handleDelete = async (id: string) => {
18 | setIsDeleting(true)
19 | await deleteLineItem(id).catch((err) => {
20 | setIsDeleting(false)
21 | })
22 | }
23 |
24 | return (
25 |
31 | handleDelete(id)}
34 | >
35 | {isDeleting ? : }
36 | {children}
37 |
38 |
39 | )
40 | }
41 |
42 | export default DeleteButton
43 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/components/divider/index.tsx:
--------------------------------------------------------------------------------
1 | import { clx } from "@medusajs/ui"
2 |
3 | const Divider = ({ className }: { className?: string }) => (
4 |
7 | )
8 |
9 | export default Divider
10 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/components/filter-radio-group/index.tsx:
--------------------------------------------------------------------------------
1 | import { EllipseMiniSolid } from "@medusajs/icons"
2 | import { Label, RadioGroup, Text, clx } from "@medusajs/ui"
3 |
4 | type FilterRadioGroupProps = {
5 | title: string
6 | items: {
7 | value: string
8 | label: string
9 | }[]
10 | value: any
11 | handleChange: (...args: any[]) => void
12 | "data-testid"?: string
13 | }
14 |
15 | const FilterRadioGroup = ({
16 | title,
17 | items,
18 | value,
19 | handleChange,
20 | "data-testid": dataTestId,
21 | }: FilterRadioGroupProps) => {
22 | return (
23 |
24 |
{title}
25 |
26 | {items?.map((i) => (
27 |
33 | {i.value === value && }
34 |
40 |
51 | {i.label}
52 |
53 |
54 | ))}
55 |
56 |
57 | )
58 | }
59 |
60 | export default FilterRadioGroup
61 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/components/interactive-link/index.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowUpRightMini } from "@medusajs/icons"
2 | import { Text } from "@medusajs/ui"
3 | import LocalizedClientLink from "../localized-client-link"
4 |
5 | type InteractiveLinkProps = {
6 | href: string
7 | children?: React.ReactNode
8 | onClick?: () => void
9 | }
10 |
11 | const InteractiveLink = ({
12 | href,
13 | children,
14 | onClick,
15 | ...props
16 | }: InteractiveLinkProps) => {
17 | return (
18 |
24 | {children}
25 |
29 |
30 | )
31 | }
32 |
33 | export default InteractiveLink
34 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/components/line-item-options/index.tsx:
--------------------------------------------------------------------------------
1 | import { HttpTypes } from "@medusajs/types"
2 | import { Text } from "@medusajs/ui"
3 |
4 | type LineItemOptionsProps = {
5 | variant: HttpTypes.StoreProductVariant | undefined
6 | "data-testid"?: string
7 | "data-value"?: HttpTypes.StoreProductVariant
8 | }
9 |
10 | const LineItemOptions = ({
11 | variant,
12 | "data-testid": dataTestid,
13 | "data-value": dataValue,
14 | }: LineItemOptionsProps) => {
15 | return (
16 |
21 | Variant: {variant?.title}
22 |
23 | )
24 | }
25 |
26 | export default LineItemOptions
27 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/components/line-item-unit-price/index.tsx:
--------------------------------------------------------------------------------
1 | import { getPricesForVariant } from "@lib/util/get-product-price"
2 | import { HttpTypes } from "@medusajs/types"
3 | import { clx } from "@medusajs/ui"
4 |
5 | type LineItemUnitPriceProps = {
6 | item: HttpTypes.StoreCartLineItem | HttpTypes.StoreOrderLineItem
7 | style?: "default" | "tight"
8 | }
9 |
10 | const LineItemUnitPrice = ({
11 | item,
12 | style = "default",
13 | }: LineItemUnitPriceProps) => {
14 | const {
15 | original_price,
16 | calculated_price,
17 | original_price_number,
18 | calculated_price_number,
19 | percentage_diff,
20 | } = getPricesForVariant(item.variant) ?? {}
21 | const hasReducedPrice = calculated_price_number < original_price_number
22 |
23 | return (
24 |
25 | {hasReducedPrice && (
26 | <>
27 |
28 | {style === "default" && (
29 | Original:
30 | )}
31 |
35 | {original_price}
36 |
37 |
38 | {style === "default" && (
39 |
-{percentage_diff}%
40 | )}
41 | >
42 | )}
43 |
49 | {calculated_price}
50 |
51 |
52 | )
53 | }
54 |
55 | export default LineItemUnitPrice
56 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/components/localized-client-link/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import Link from "next/link"
4 | import { useParams } from "next/navigation"
5 | import React from "react"
6 |
7 | /**
8 | * Use this component to create a Next.js ` ` that persists the current country code in the url,
9 | * without having to explicitly pass it as a prop.
10 | */
11 | const LocalizedClientLink = ({
12 | children,
13 | href,
14 | ...props
15 | }: {
16 | children?: React.ReactNode
17 | href: string
18 | className?: string
19 | onClick?: () => void
20 | passHref?: true
21 | [x: string]: any
22 | }) => {
23 | const { countryCode } = useParams()
24 |
25 | return (
26 |
27 | {children}
28 |
29 | )
30 | }
31 |
32 | export default LocalizedClientLink
33 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/components/radio/index.tsx:
--------------------------------------------------------------------------------
1 | const Radio = ({ checked, 'data-testid': dataTestId }: { checked: boolean, 'data-testid'?: string }) => {
2 | return (
3 | <>
4 |
12 |
13 | {checked && (
14 |
18 |
19 |
20 | )}
21 |
22 |
23 | >
24 | )
25 | }
26 |
27 | export default Radio
28 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/back.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const Back: React.FC = ({
6 | size = "16",
7 | color = "currentColor",
8 | ...attributes
9 | }) => {
10 | return (
11 |
19 |
26 |
33 |
34 | )
35 | }
36 |
37 | export default Back
38 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/bancontact.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const Ideal: React.FC = ({
6 | size = "20",
7 | color = "currentColor",
8 | ...attributes
9 | }) => {
10 | return (
11 |
20 | Bancontact icon
21 |
22 |
23 | )
24 | }
25 |
26 | export default Ideal
27 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/chevron-down.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const ChevronDown: React.FC = ({
6 | size = "16",
7 | color = "currentColor",
8 | ...attributes
9 | }) => {
10 | return (
11 |
19 |
26 |
27 | )
28 | }
29 |
30 | export default ChevronDown
31 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/eye-off.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const EyeOff: React.FC = ({
6 | size = "20",
7 | color = "currentColor",
8 | ...attributes
9 | }) => {
10 | return (
11 |
19 |
26 |
33 |
34 | )
35 | }
36 |
37 | export default EyeOff
38 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/eye.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const Eye: React.FC = ({
6 | size = "20",
7 | color = "currentColor",
8 | ...attributes
9 | }) => {
10 | return (
11 |
19 |
26 |
33 |
34 | )
35 | }
36 |
37 | export default Eye
38 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/fast-delivery.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const FastDelivery: React.FC = ({
6 | size = "16",
7 | color = "currentColor",
8 | ...attributes
9 | }) => {
10 | return (
11 |
19 |
26 |
33 |
40 |
47 |
54 |
61 |
62 | )
63 | }
64 |
65 | export default FastDelivery
66 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/ideal.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const Ideal: React.FC = ({
6 | size = "20",
7 | color = "currentColor",
8 | ...attributes
9 | }) => {
10 | return (
11 |
20 | iDEAL icon
21 |
22 |
23 | )
24 | }
25 |
26 | export default Ideal
27 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/map-pin.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const MapPin: React.FC = ({
6 | size = "20",
7 | color = "currentColor",
8 | ...attributes
9 | }) => {
10 | return (
11 |
19 |
26 |
33 |
34 | )
35 | }
36 |
37 | export default MapPin
38 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/medusa.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const Medusa: React.FC = ({
6 | size = "20",
7 | color = "#9CA3AF",
8 | ...attributes
9 | }) => {
10 | return (
11 |
19 |
23 |
24 | )
25 | }
26 |
27 | export default Medusa
28 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/package.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const Package: React.FC = ({
6 | size = "20",
7 | color = "currentColor",
8 | ...attributes
9 | }) => {
10 | return (
11 |
19 |
26 |
33 |
40 |
41 | )
42 | }
43 |
44 | export default Package
45 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/paypal.tsx:
--------------------------------------------------------------------------------
1 | const PayPal = () => {
2 | return (
3 |
10 |
18 |
26 |
27 | )
28 | }
29 |
30 | export default PayPal
31 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/placeholder-image.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const PlaceholderImage: React.FC = ({
6 | size = "20",
7 | color = "currentColor",
8 | ...attributes
9 | }) => {
10 | return (
11 |
19 |
26 |
33 |
40 |
41 | )
42 | }
43 |
44 | export default PlaceholderImage
45 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/refresh.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const Refresh: React.FC = ({
6 | size = "16",
7 | color = "currentColor",
8 | ...attributes
9 | }) => {
10 | return (
11 |
19 |
26 |
33 |
40 |
47 |
48 | )
49 | }
50 |
51 | export default Refresh
52 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/spinner.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const Spinner: React.FC = ({
6 | size = "16",
7 | color = "currentColor",
8 | ...attributes
9 | }) => {
10 | return (
11 |
20 |
28 |
33 |
34 | )
35 | }
36 |
37 | export default Spinner
38 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/trash.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const Trash: React.FC = ({
6 | size = "16",
7 | color = "currentColor",
8 | ...attributes
9 | }) => {
10 | return (
11 |
19 |
26 |
33 |
40 |
47 |
48 | )
49 | }
50 |
51 | export default Trash
52 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/user.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const User: React.FC = ({
6 | size = "16",
7 | color = "currentColor",
8 | ...attributes
9 | }) => {
10 | return (
11 |
19 |
26 |
33 |
34 | )
35 | }
36 |
37 | export default User
38 |
--------------------------------------------------------------------------------
/storefront/src/modules/common/icons/x.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import { IconProps } from "types/icon"
4 |
5 | const X: React.FC = ({
6 | size = "20",
7 | color = "currentColor",
8 | ...attributes
9 | }) => {
10 | return (
11 |
19 |
26 |
33 |
34 | )
35 | }
36 |
37 | export default X
38 |
--------------------------------------------------------------------------------
/storefront/src/modules/home/components/featured-products/index.tsx:
--------------------------------------------------------------------------------
1 | import { HttpTypes } from "@medusajs/types"
2 | import ProductRail from "@modules/home/components/featured-products/product-rail"
3 |
4 | export default async function FeaturedProducts({
5 | collections,
6 | region,
7 | }: {
8 | collections: HttpTypes.StoreCollection[]
9 | region: HttpTypes.StoreRegion
10 | }) {
11 | return collections.map((collection) => (
12 |
13 |
14 |
15 | ))
16 | }
17 |
--------------------------------------------------------------------------------
/storefront/src/modules/home/components/featured-products/product-rail/index.tsx:
--------------------------------------------------------------------------------
1 | import { HttpTypes } from "@medusajs/types"
2 | import { Text } from "@medusajs/ui"
3 |
4 | import InteractiveLink from "@modules/common/components/interactive-link"
5 | import ProductPreview from "@modules/products/components/product-preview"
6 |
7 | export default function ProductRail({
8 | collection,
9 | region,
10 | }: {
11 | collection: HttpTypes.StoreCollection
12 | region: HttpTypes.StoreRegion
13 | }) {
14 | const { products } = collection
15 |
16 | if (!products) {
17 | return null
18 | }
19 |
20 | return (
21 |
22 |
23 | {collection.title}
24 |
25 | View all
26 |
27 |
28 |
29 | {products &&
30 | products.map((product) => (
31 |
32 | {/* @ts-ignore */}
33 |
34 |
35 | ))}
36 |
37 |
38 | )
39 | }
40 |
--------------------------------------------------------------------------------
/storefront/src/modules/home/components/hero/index.tsx:
--------------------------------------------------------------------------------
1 | import { Github } from "@medusajs/icons"
2 | import { Button, Heading } from "@medusajs/ui"
3 |
4 | const Hero = () => {
5 | return (
6 |
7 |
8 |
9 |
13 | Well done! You have successfully deployed your Medusa 2.0 store on Railway!
14 |
15 |
19 | Need help customizing your store?
20 |
21 |
22 |
26 |
27 | Visit the tutorial
28 |
29 |
30 |
31 |
32 | )
33 | }
34 |
35 | export default Hero
36 |
--------------------------------------------------------------------------------
/storefront/src/modules/layout/components/cart-button/index.tsx:
--------------------------------------------------------------------------------
1 | import { notFound } from "next/navigation"
2 | import CartDropdown from "../cart-dropdown"
3 | import { enrichLineItems, retrieveCart } from "@lib/data/cart"
4 |
5 | const fetchCart = async () => {
6 | const cart = await retrieveCart()
7 |
8 | if (!cart) {
9 | return null
10 | }
11 |
12 | if (cart?.items?.length) {
13 | const enrichedItems = await enrichLineItems(cart.items, cart.region_id!)
14 | cart.items = enrichedItems
15 | }
16 |
17 | return cart
18 | }
19 |
20 | export default async function CartButton() {
21 | const cart = await fetchCart()
22 |
23 | return
24 | }
25 |
--------------------------------------------------------------------------------
/storefront/src/modules/layout/components/medusa-cta/index.tsx:
--------------------------------------------------------------------------------
1 | import { Text } from "@medusajs/ui"
2 |
3 | import Medusa from "../../../common/icons/medusa"
4 | import NextJs from "../../../common/icons/nextjs"
5 |
6 | const MedusaCTA = () => {
7 | return (
8 |
9 | Powered by
10 |
11 |
12 |
13 | &
14 |
15 |
16 |
17 |
18 | )
19 | }
20 |
21 | export default MedusaCTA
22 |
--------------------------------------------------------------------------------
/storefront/src/modules/layout/templates/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | import Footer from "@modules/layout/templates/footer"
4 | import Nav from "@modules/layout/templates/nav"
5 |
6 | const Layout: React.FC<{
7 | children: React.ReactNode
8 | }> = ({ children }) => {
9 | return (
10 |
11 |
12 | {children}
13 |
14 |
15 | )
16 | }
17 |
18 | export default Layout
19 |
--------------------------------------------------------------------------------
/storefront/src/modules/order/components/help/index.tsx:
--------------------------------------------------------------------------------
1 | import { Heading } from "@medusajs/ui"
2 | import LocalizedClientLink from "@modules/common/components/localized-client-link"
3 | import React from "react"
4 |
5 | const Help = () => {
6 | return (
7 |
8 |
Need help?
9 |
10 |
11 |
12 | Contact
13 |
14 |
15 |
16 | Returns & Exchanges
17 |
18 |
19 |
20 |
21 |
22 | )
23 | }
24 |
25 | export default Help
26 |
--------------------------------------------------------------------------------
/storefront/src/modules/order/components/item/index.tsx:
--------------------------------------------------------------------------------
1 | import { HttpTypes } from "@medusajs/types"
2 | import { Table, Text } from "@medusajs/ui"
3 |
4 | import LineItemOptions from "@modules/common/components/line-item-options"
5 | import LineItemPrice from "@modules/common/components/line-item-price"
6 | import LineItemUnitPrice from "@modules/common/components/line-item-unit-price"
7 | import Thumbnail from "@modules/products/components/thumbnail"
8 |
9 | type ItemProps = {
10 | item: HttpTypes.StoreCartLineItem | HttpTypes.StoreOrderLineItem
11 | }
12 |
13 | const Item = ({ item }: ItemProps) => {
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
27 | {item.title}
28 |
29 | {item.variant && (
30 |
31 | )}
32 |
33 |
34 |
35 |
36 |
37 |
38 | {item.quantity} x{" "}
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | )
48 | }
49 |
50 | export default Item
51 |
--------------------------------------------------------------------------------
/storefront/src/modules/order/components/items/index.tsx:
--------------------------------------------------------------------------------
1 | import repeat from "@lib/util/repeat"
2 | import { HttpTypes } from "@medusajs/types"
3 | import { Table } from "@medusajs/ui"
4 |
5 | import Divider from "@modules/common/components/divider"
6 | import Item from "@modules/order/components/item"
7 | import SkeletonLineItem from "@modules/skeletons/components/skeleton-line-item"
8 |
9 | type ItemsProps = {
10 | items: HttpTypes.StoreCartLineItem[] | HttpTypes.StoreOrderLineItem[] | null
11 | }
12 |
13 | const Items = ({ items }: ItemsProps) => {
14 | return (
15 |
16 |
17 |
18 |
19 | {items?.length
20 | ? items
21 | .sort((a, b) => {
22 | return (a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1
23 | })
24 | .map((item) => {
25 | return
26 | })
27 | : repeat(5).map((i) => {
28 | return
29 | })}
30 |
31 |
32 |
33 | )
34 | }
35 |
36 | export default Items
37 |
--------------------------------------------------------------------------------
/storefront/src/modules/order/components/onboarding-cta/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { resetOnboardingState } from "@lib/data/onboarding"
4 | import { Button, Container, Text } from "@medusajs/ui"
5 |
6 | const OnboardingCta = ({ orderId }: { orderId: string }) => {
7 | return (
8 |
9 |
10 |
11 | Your test order was successfully created! 🎉
12 |
13 |
14 | You can now complete setting up your store in the admin.
15 |
16 | resetOnboardingState(orderId)}
20 | >
21 | Complete setup in admin
22 |
23 |
24 |
25 | )
26 | }
27 |
28 | export default OnboardingCta
29 |
--------------------------------------------------------------------------------
/storefront/src/modules/order/templates/order-completed-template.tsx:
--------------------------------------------------------------------------------
1 | import { Heading } from "@medusajs/ui"
2 | import { cookies } from "next/headers"
3 |
4 | import CartTotals from "@modules/common/components/cart-totals"
5 | import Help from "@modules/order/components/help"
6 | import Items from "@modules/order/components/items"
7 | import OnboardingCta from "@modules/order/components/onboarding-cta"
8 | import OrderDetails from "@modules/order/components/order-details"
9 | import ShippingDetails from "@modules/order/components/shipping-details"
10 | import PaymentDetails from "@modules/order/components/payment-details"
11 | import { HttpTypes } from "@medusajs/types"
12 |
13 | type OrderCompletedTemplateProps = {
14 | order: HttpTypes.StoreOrder
15 | }
16 |
17 | export default function OrderCompletedTemplate({
18 | order,
19 | }: OrderCompletedTemplateProps) {
20 | const isOnboarding = cookies().get("_medusa_onboarding")?.value === "true"
21 |
22 | return (
23 |
24 |
25 | {isOnboarding &&
}
26 |
30 |
34 | Thank you!
35 | Your order was placed successfully.
36 |
37 |
38 |
39 | Summary
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/storefront/src/modules/order/templates/order-details-template.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { XMark } from "@medusajs/icons"
4 | import React from "react"
5 |
6 | import Help from "@modules/order/components/help"
7 | import Items from "@modules/order/components/items"
8 | import OrderDetails from "@modules/order/components/order-details"
9 | import OrderSummary from "@modules/order/components/order-summary"
10 | import ShippingDetails from "@modules/order/components/shipping-details"
11 | import LocalizedClientLink from "@modules/common/components/localized-client-link"
12 | import { HttpTypes } from "@medusajs/types"
13 |
14 | type OrderDetailsTemplateProps = {
15 | order: HttpTypes.StoreOrder
16 | }
17 |
18 | const OrderDetailsTemplate: React.FC = ({
19 | order,
20 | }) => {
21 | return (
22 |
23 |
24 |
Order details
25 |
30 | Back to overview
31 |
32 |
33 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | )
45 | }
46 |
47 | export default OrderDetailsTemplate
48 |
--------------------------------------------------------------------------------
/storefront/src/modules/products/components/image-gallery/index.tsx:
--------------------------------------------------------------------------------
1 | import { HttpTypes } from "@medusajs/types"
2 | import { Container } from "@medusajs/ui"
3 | import Image from "next/image"
4 |
5 | type ImageGalleryProps = {
6 | images: HttpTypes.StoreProductImage[]
7 | }
8 |
9 | const ImageGallery = ({ images }: ImageGalleryProps) => {
10 | return (
11 |
12 |
13 | {images.map((image, index) => {
14 | return (
15 |
20 | {!!image.url && (
21 |
32 | )}
33 |
34 | )
35 | })}
36 |
37 |
38 | )
39 | }
40 |
41 | export default ImageGallery
42 |
--------------------------------------------------------------------------------
/storefront/src/modules/products/components/product-actions/option-select.tsx:
--------------------------------------------------------------------------------
1 | import { HttpTypes } from "@medusajs/types"
2 | import { clx } from "@medusajs/ui"
3 | import React from "react"
4 |
5 | type OptionSelectProps = {
6 | option: HttpTypes.StoreProductOption
7 | current: string | undefined
8 | updateOption: (title: string, value: string) => void
9 | title: string
10 | disabled: boolean
11 | "data-testid"?: string
12 | }
13 |
14 | const OptionSelect: React.FC = ({
15 | option,
16 | current,
17 | updateOption,
18 | title,
19 | "data-testid": dataTestId,
20 | disabled,
21 | }) => {
22 | const filteredOptions = option.values?.map((v) => v.value)
23 |
24 | return (
25 |
26 |
Select {title}
27 |
31 | {filteredOptions?.map((v) => {
32 | return (
33 | updateOption(option.title ?? "", v ?? "")}
35 | key={v}
36 | className={clx(
37 | "border-ui-border-base bg-ui-bg-subtle border text-small-regular h-10 rounded-rounded p-2 flex-1 ",
38 | {
39 | "border-ui-border-interactive": v === current,
40 | "hover:shadow-elevation-card-rest transition-shadow ease-in-out duration-150":
41 | v !== current,
42 | }
43 | )}
44 | disabled={disabled}
45 | data-testid="option-button"
46 | >
47 | {v}
48 |
49 | )
50 | })}
51 |
52 |
53 | )
54 | }
55 |
56 | export default OptionSelect
57 |
--------------------------------------------------------------------------------
/storefront/src/modules/products/components/product-onboarding-cta/index.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Container, Text } from "@medusajs/ui"
2 | import { cookies } from "next/headers"
3 |
4 | const ProductOnboardingCta = () => {
5 | const isOnboarding = cookies().get("_medusa_onboarding")?.value === "true"
6 |
7 | if (!isOnboarding) {
8 | return null
9 | }
10 |
11 | return (
12 |
13 |
14 |
15 | Your demo product was successfully created! 🎉
16 |
17 |
18 | You can now continue setting up your store in the admin.
19 |
20 |
21 | Continue setup in admin
22 |
23 |
24 |
25 | )
26 | }
27 |
28 | export default ProductOnboardingCta
29 |
--------------------------------------------------------------------------------
/storefront/src/modules/products/components/product-preview/index.tsx:
--------------------------------------------------------------------------------
1 | import { Text } from "@medusajs/ui"
2 |
3 | import { getProductPrice } from "@lib/util/get-product-price"
4 | import LocalizedClientLink from "@modules/common/components/localized-client-link"
5 | import Thumbnail from "../thumbnail"
6 | import PreviewPrice from "./price"
7 | import { getProductsById } from "@lib/data/products"
8 | import { HttpTypes } from "@medusajs/types"
9 |
10 | export default async function ProductPreview({
11 | product,
12 | isFeatured,
13 | region,
14 | }: {
15 | product: HttpTypes.StoreProduct
16 | isFeatured?: boolean
17 | region: HttpTypes.StoreRegion
18 | }) {
19 | const [pricedProduct] = await getProductsById({
20 | ids: [product.id!],
21 | regionId: region.id,
22 | })
23 |
24 | if (!pricedProduct) {
25 | return null
26 | }
27 |
28 | const { cheapestPrice } = getProductPrice({
29 | product: pricedProduct,
30 | })
31 |
32 | return (
33 |
34 |
35 |
41 |
42 |
43 | {product.title}
44 |
45 |
46 | {cheapestPrice &&
}
47 |
48 |
49 |
50 |
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/storefront/src/modules/products/components/product-preview/price.tsx:
--------------------------------------------------------------------------------
1 | import { Text, clx } from "@medusajs/ui"
2 | import { VariantPrice } from "types/global"
3 |
4 | export default async function PreviewPrice({ price }: { price: VariantPrice }) {
5 | if (!price) {
6 | return null
7 | }
8 |
9 | return (
10 | <>
11 | {price.price_type === "sale" && (
12 |
16 | {price.original_price}
17 |
18 | )}
19 |
25 | {price.calculated_price}
26 |
27 | >
28 | )
29 | }
30 |
--------------------------------------------------------------------------------
/storefront/src/modules/products/components/product-price/index.tsx:
--------------------------------------------------------------------------------
1 | import { clx } from "@medusajs/ui"
2 |
3 | import { getProductPrice } from "@lib/util/get-product-price"
4 | import { HttpTypes } from "@medusajs/types"
5 |
6 | export default function ProductPrice({
7 | product,
8 | variant,
9 | }: {
10 | product: HttpTypes.StoreProduct
11 | variant?: HttpTypes.StoreProductVariant
12 | }) {
13 | const { cheapestPrice, variantPrice } = getProductPrice({
14 | product,
15 | variantId: variant?.id,
16 | })
17 |
18 | const selectedPrice = variant ? variantPrice : cheapestPrice
19 |
20 | if (!selectedPrice) {
21 | return
22 | }
23 |
24 | return (
25 |
26 |
31 | {!variant && "From "}
32 |
36 | {selectedPrice.calculated_price}
37 |
38 |
39 | {selectedPrice.price_type === "sale" && (
40 | <>
41 |
42 | Original:
43 |
48 | {selectedPrice.original_price}
49 |
50 |
51 |
52 | -{selectedPrice.percentage_diff}%
53 |
54 | >
55 | )}
56 |
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/storefront/src/modules/products/templates/product-actions-wrapper/index.tsx:
--------------------------------------------------------------------------------
1 | import { getProductsById } from "@lib/data/products"
2 | import { HttpTypes } from "@medusajs/types"
3 | import ProductActions from "@modules/products/components/product-actions"
4 |
5 | /**
6 | * Fetches real time pricing for a product and renders the product actions component.
7 | */
8 | export default async function ProductActionsWrapper({
9 | id,
10 | region,
11 | }: {
12 | id: string
13 | region: HttpTypes.StoreRegion
14 | }) {
15 | const [product] = await getProductsById({
16 | ids: [id],
17 | regionId: region.id,
18 | })
19 |
20 | if (!product) {
21 | return null
22 | }
23 |
24 | return
25 | }
26 |
--------------------------------------------------------------------------------
/storefront/src/modules/products/templates/product-info/index.tsx:
--------------------------------------------------------------------------------
1 | import { HttpTypes } from "@medusajs/types"
2 | import { Heading, Text } from "@medusajs/ui"
3 | import LocalizedClientLink from "@modules/common/components/localized-client-link"
4 |
5 | type ProductInfoProps = {
6 | product: HttpTypes.StoreProduct
7 | }
8 |
9 | const ProductInfo = ({ product }: ProductInfoProps) => {
10 | return (
11 |
12 |
13 | {product.collection && (
14 |
18 | {product.collection.title}
19 |
20 | )}
21 |
26 | {product.title}
27 |
28 |
29 |
33 | {product.description}
34 |
35 |
36 |
37 | )
38 | }
39 |
40 | export default ProductInfo
41 |
--------------------------------------------------------------------------------
/storefront/src/modules/search/actions.ts:
--------------------------------------------------------------------------------
1 | "use server"
2 |
3 | import { SEARCH_INDEX_NAME, searchClient } from "@lib/search-client"
4 |
5 | interface Hits {
6 | readonly objectID?: string
7 | id?: string
8 | [x: string | number | symbol]: unknown
9 | }
10 |
11 | /**
12 | * Uses MeiliSearch or Algolia to search for a query
13 | * @param {string} query - search query
14 | */
15 | export async function search(query: string) {
16 | // MeiliSearch
17 | const queries = [{ params: { query }, indexName: SEARCH_INDEX_NAME }]
18 | const { results } = (await searchClient.search(queries)) as Record<
19 | string,
20 | any
21 | >
22 | const { hits } = results[0] as { hits: Hits[] }
23 |
24 | // In case you want to use Algolia instead of MeiliSearch, uncomment the following lines and delete the above lines.
25 |
26 | // const index = searchClient.initIndex(SEARCH_INDEX_NAME)
27 | // const { hits } = (await index.search(query)) as { hits: Hits[] }
28 |
29 | return hits
30 | }
31 |
--------------------------------------------------------------------------------
/storefront/src/modules/search/components/hit/index.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Text } from "@medusajs/ui"
2 |
3 | import Thumbnail from "@modules/products/components/thumbnail"
4 | import LocalizedClientLink from "@modules/common/components/localized-client-link"
5 | import { HttpTypes } from "@medusajs/types"
6 |
7 | export type ProductHit = {
8 | id: string
9 | title: string
10 | handle: string
11 | description: string | null
12 | thumbnail: string | null
13 | variants: HttpTypes.StoreProductVariant[]
14 | collection_handle: string | null
15 | collection_id: string | null
16 | }
17 |
18 | type HitProps = {
19 | hit: ProductHit
20 | }
21 |
22 | const Hit = ({ hit }: HitProps) => {
23 | return (
24 |
28 |
32 |
37 |
38 |
39 |
43 | {hit.title}
44 |
45 |
46 |
47 |
48 |
49 | )
50 | }
51 |
52 | export default Hit
53 |
--------------------------------------------------------------------------------
/storefront/src/modules/search/components/hits/index.tsx:
--------------------------------------------------------------------------------
1 | import { clx } from "@medusajs/ui"
2 | import React from "react"
3 | import {
4 | UseHitsProps,
5 | useHits,
6 | useSearchBox,
7 | } from "react-instantsearch-hooks-web"
8 |
9 | import { ProductHit } from "../hit"
10 | import ShowAll from "../show-all"
11 |
12 | type HitsProps = React.ComponentProps<"div"> &
13 | UseHitsProps & {
14 | hitComponent: (props: { hit: THit }) => JSX.Element
15 | }
16 |
17 | const Hits = ({
18 | hitComponent: Hit,
19 | className,
20 | ...props
21 | }: HitsProps) => {
22 | const { query } = useSearchBox()
23 | const { hits } = useHits(props)
24 |
25 | return (
26 |
36 |
40 | {hits.slice(0, 6).map((hit, index) => (
41 |
2,
45 | })}
46 | >
47 |
48 |
49 | ))}
50 |
51 |
52 |
53 | )
54 | }
55 |
56 | export default Hits
57 |
--------------------------------------------------------------------------------
/storefront/src/modules/search/components/show-all/index.tsx:
--------------------------------------------------------------------------------
1 | import { Container, Text } from "@medusajs/ui"
2 | import { useHits, useSearchBox } from "react-instantsearch-hooks-web"
3 |
4 | import InteractiveLink from "@modules/common/components/interactive-link"
5 |
6 | const ShowAll = () => {
7 | const { hits } = useHits()
8 | const { query } = useSearchBox()
9 | const width = typeof window !== "undefined" ? window.innerWidth : 0
10 |
11 | if (query === "") return null
12 | if (hits.length > 0 && hits.length <= 6) return null
13 |
14 | if (hits.length === 0) {
15 | return (
16 |
20 | No results found.
21 |
22 | )
23 | }
24 |
25 | return (
26 |
27 | Showing the first {width > 640 ? 6 : 3} results.
28 | View all
29 |
30 | )
31 | }
32 |
33 | export default ShowAll
34 |
--------------------------------------------------------------------------------
/storefront/src/modules/skeletons/components/skeleton-button/index.tsx:
--------------------------------------------------------------------------------
1 | const SkeletonButton = () => {
2 | return
3 | }
4 |
5 | export default SkeletonButton
6 |
--------------------------------------------------------------------------------
/storefront/src/modules/skeletons/components/skeleton-cart-item/index.tsx:
--------------------------------------------------------------------------------
1 | import { Table } from "@medusajs/ui"
2 |
3 | const SkeletonCartItem = () => {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
16 |
20 |
21 |
22 |
25 |
26 |
27 |
30 |
31 |
32 | )
33 | }
34 |
35 | export default SkeletonCartItem
36 |
--------------------------------------------------------------------------------
/storefront/src/modules/skeletons/components/skeleton-cart-totals/index.tsx:
--------------------------------------------------------------------------------
1 | const SkeletonCartTotals = ({ header = true }) => {
2 | return (
3 |
4 | {header &&
}
5 |
9 |
10 |
14 |
15 |
19 |
20 |
21 |
22 |
26 |
27 | )
28 | }
29 |
30 | export default SkeletonCartTotals
31 |
--------------------------------------------------------------------------------
/storefront/src/modules/skeletons/components/skeleton-code-form/index.tsx:
--------------------------------------------------------------------------------
1 | const SkeletonCodeForm = () => {
2 | return (
3 |
10 | )
11 | }
12 |
13 | export default SkeletonCodeForm
14 |
--------------------------------------------------------------------------------
/storefront/src/modules/skeletons/components/skeleton-line-item/index.tsx:
--------------------------------------------------------------------------------
1 | import { Table } from "@medusajs/ui"
2 |
3 | const SkeletonLineItem = () => {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
14 |
15 |
16 |
20 |
21 |
22 |
25 |
26 |
27 |
30 |
31 |
32 | )
33 | }
34 |
35 | export default SkeletonLineItem
36 |
--------------------------------------------------------------------------------
/storefront/src/modules/skeletons/components/skeleton-order-confirmed-header/index.tsx:
--------------------------------------------------------------------------------
1 | const SkeletonOrderConfirmedHeader = () => {
2 | return (
3 |
11 | )
12 | }
13 |
14 | export default SkeletonOrderConfirmedHeader
15 |
--------------------------------------------------------------------------------
/storefront/src/modules/skeletons/components/skeleton-order-information/index.tsx:
--------------------------------------------------------------------------------
1 | import SkeletonCartTotals from "@modules/skeletons/components/skeleton-cart-totals"
2 |
3 | const SkeletonOrderInformation = () => {
4 | return (
5 |
6 |
7 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
32 |
33 | )
34 | }
35 |
36 | export default SkeletonOrderInformation
37 |
--------------------------------------------------------------------------------
/storefront/src/modules/skeletons/components/skeleton-order-items/index.tsx:
--------------------------------------------------------------------------------
1 | const SkeletonOrderItems = () => {
2 | return (
3 |
4 |
15 |
16 |
27 |
28 |
39 |
40 | )
41 | }
42 |
43 | export default SkeletonOrderItems
44 |
--------------------------------------------------------------------------------
/storefront/src/modules/skeletons/components/skeleton-order-summary/index.tsx:
--------------------------------------------------------------------------------
1 | import SkeletonButton from "@modules/skeletons/components/skeleton-button"
2 | import SkeletonCartTotals from "@modules/skeletons/components/skeleton-cart-totals"
3 |
4 | const SkeletonOrderSummary = () => {
5 | return (
6 |
12 | )
13 | }
14 |
15 | export default SkeletonOrderSummary
16 |
--------------------------------------------------------------------------------
/storefront/src/modules/skeletons/components/skeleton-product-preview/index.tsx:
--------------------------------------------------------------------------------
1 | import { Container } from "@medusajs/ui"
2 |
3 | const SkeletonProductPreview = () => {
4 | return (
5 |
12 | )
13 | }
14 |
15 | export default SkeletonProductPreview
16 |
--------------------------------------------------------------------------------
/storefront/src/modules/skeletons/templates/skeleton-order-confirmed/index.tsx:
--------------------------------------------------------------------------------
1 | import SkeletonOrderConfirmedHeader from "@modules/skeletons/components/skeleton-order-confirmed-header"
2 | import SkeletonOrderInformation from "@modules/skeletons/components/skeleton-order-information"
3 | import SkeletonOrderItems from "@modules/skeletons/components/skeleton-order-items"
4 |
5 | const SkeletonOrderConfirmed = () => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | )
19 | }
20 |
21 | export default SkeletonOrderConfirmed
22 |
--------------------------------------------------------------------------------
/storefront/src/modules/skeletons/templates/skeleton-product-grid/index.tsx:
--------------------------------------------------------------------------------
1 | import repeat from "@lib/util/repeat"
2 | import SkeletonProductPreview from "@modules/skeletons/components/skeleton-product-preview"
3 |
4 | const SkeletonProductGrid = () => {
5 | return (
6 |
7 | {repeat(8).map((index) => (
8 |
9 |
10 |
11 | ))}
12 |
13 | )
14 | }
15 |
16 | export default SkeletonProductGrid
17 |
--------------------------------------------------------------------------------
/storefront/src/modules/skeletons/templates/skeleton-related-products/index.tsx:
--------------------------------------------------------------------------------
1 | import repeat from "@lib/util/repeat"
2 | import SkeletonProductPreview from "@modules/skeletons/components/skeleton-product-preview"
3 |
4 | const SkeletonRelatedProducts = () => {
5 | return (
6 |
7 |
14 |
15 | {repeat(3).map((index) => (
16 |
17 |
18 |
19 | ))}
20 |
21 |
22 | )
23 | }
24 |
25 | export default SkeletonRelatedProducts
26 |
--------------------------------------------------------------------------------
/storefront/src/modules/store/components/refinement-list/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { usePathname, useRouter, useSearchParams } from "next/navigation"
4 | import { useCallback } from "react"
5 |
6 | import SortProducts, { SortOptions } from "./sort-products"
7 |
8 | type RefinementListProps = {
9 | sortBy: SortOptions
10 | search?: boolean
11 | 'data-testid'?: string
12 | }
13 |
14 | const RefinementList = ({ sortBy, 'data-testid': dataTestId }: RefinementListProps) => {
15 | const router = useRouter()
16 | const pathname = usePathname()
17 | const searchParams = useSearchParams()
18 |
19 | const createQueryString = useCallback(
20 | (name: string, value: string) => {
21 | const params = new URLSearchParams(searchParams)
22 | params.set(name, value)
23 |
24 | return params.toString()
25 | },
26 | [searchParams]
27 | )
28 |
29 | const setQueryParams = (name: string, value: string) => {
30 | const query = createQueryString(name, value)
31 | router.push(`${pathname}?${query}`)
32 | }
33 |
34 | return (
35 |
36 |
37 |
38 | )
39 | }
40 |
41 | export default RefinementList
42 |
--------------------------------------------------------------------------------
/storefront/src/modules/store/components/refinement-list/sort-products/index.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import FilterRadioGroup from "@modules/common/components/filter-radio-group"
4 |
5 | export type SortOptions = "price_asc" | "price_desc" | "created_at"
6 |
7 | type SortProductsProps = {
8 | sortBy: SortOptions
9 | setQueryParams: (name: string, value: SortOptions) => void
10 | "data-testid"?: string
11 | }
12 |
13 | const sortOptions = [
14 | {
15 | value: "created_at",
16 | label: "Latest Arrivals",
17 | },
18 | {
19 | value: "price_asc",
20 | label: "Price: Low -> High",
21 | },
22 | {
23 | value: "price_desc",
24 | label: "Price: High -> Low",
25 | },
26 | ]
27 |
28 | const SortProducts = ({
29 | "data-testid": dataTestId,
30 | sortBy,
31 | setQueryParams,
32 | }: SortProductsProps) => {
33 | const handleChange = (value: SortOptions) => {
34 | setQueryParams("sortBy", value)
35 | }
36 |
37 | return (
38 |
45 | )
46 | }
47 |
48 | export default SortProducts
49 |
--------------------------------------------------------------------------------
/storefront/src/modules/store/templates/index.tsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from "react"
2 |
3 | import SkeletonProductGrid from "@modules/skeletons/templates/skeleton-product-grid"
4 | import RefinementList from "@modules/store/components/refinement-list"
5 | import { SortOptions } from "@modules/store/components/refinement-list/sort-products"
6 |
7 | import PaginatedProducts from "./paginated-products"
8 |
9 | const StoreTemplate = ({
10 | sortBy,
11 | page,
12 | countryCode,
13 | }: {
14 | sortBy?: SortOptions
15 | page?: string
16 | countryCode: string
17 | }) => {
18 | const pageNumber = page ? parseInt(page) : 1
19 | const sort = sortBy || "created_at"
20 |
21 | return (
22 |
26 |
27 |
28 |
29 |
All products
30 |
31 |
}>
32 |
37 |
38 |
39 |
40 | )
41 | }
42 |
43 | export default StoreTemplate
44 |
--------------------------------------------------------------------------------
/storefront/src/types/global.ts:
--------------------------------------------------------------------------------
1 | export type FeaturedProduct = {
2 | id: string
3 | title: string
4 | handle: string
5 | thumbnail?: string
6 | }
7 |
8 | export type VariantPrice = {
9 | calculated_price_number: number
10 | calculated_price: string
11 | original_price_number: number
12 | original_price: string
13 | currency_code: string
14 | price_type: string
15 | percentage_diff: string
16 | }
17 |
--------------------------------------------------------------------------------
/storefront/src/types/icon.ts:
--------------------------------------------------------------------------------
1 | export type IconProps = {
2 | color?: string
3 | size?: string | number
4 | } & React.SVGAttributes
5 |
--------------------------------------------------------------------------------
/storefront/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "baseUrl": "./src",
18 | "paths": {
19 | "@lib/*": ["lib/*"],
20 | "@modules/*": ["modules/*"],
21 | "@pages/*": ["pages/*"]
22 | },
23 | "plugins": [
24 | {
25 | "name": "next"
26 | }
27 | ]
28 | },
29 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
30 | "exclude": [
31 | "node_modules",
32 | ".next",
33 | ".nyc_output",
34 | "coverage",
35 | "jest-coverage"
36 | ]
37 | }
38 |
--------------------------------------------------------------------------------