Showing preview only (1,112K chars total). Download the full file or copy to clipboard to get everything.
Repository: Agilo/fashion-starter
Branch: master
Commit: a2c31cc781cb
Files: 379
Total size: 1002.1 KB
Directory structure:
gitextract_9kshcczh/
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ └── workflows/
│ └── node.js.yml
├── .gitignore
├── LICENSE
├── README.md
├── medusa/
│ ├── .gitignore
│ ├── .npmrc
│ ├── .vscode/
│ │ └── settings.json
│ ├── .yarnrc.yml
│ ├── README.md
│ ├── docker-compose.yml
│ ├── instrumentation.js
│ ├── integration-tests/
│ │ └── http/
│ │ ├── README.md
│ │ └── health.spec.ts
│ ├── jest.config.js
│ ├── medusa-config.js
│ ├── package.json
│ ├── src/
│ │ ├── admin/
│ │ │ ├── README.md
│ │ │ ├── components/
│ │ │ │ ├── EditMaterialDrawer.tsx
│ │ │ │ ├── Form/
│ │ │ │ │ ├── Form.tsx
│ │ │ │ │ ├── ImageField.tsx
│ │ │ │ │ ├── InputField.tsx
│ │ │ │ │ ├── SelectField.tsx
│ │ │ │ │ ├── SubmitButton.tsx
│ │ │ │ │ └── TextareaField.tsx
│ │ │ │ └── QueryClientProvider.tsx
│ │ │ ├── hooks/
│ │ │ │ ├── fashion.ts
│ │ │ │ └── images.ts
│ │ │ ├── routes/
│ │ │ │ └── fashion/
│ │ │ │ ├── [id]/
│ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── tsconfig.json
│ │ │ └── widgets/
│ │ │ ├── collection-details.tsx
│ │ │ ├── product-fashion.tsx
│ │ │ └── product-type-details.tsx
│ │ ├── api/
│ │ │ ├── README.md
│ │ │ ├── admin/
│ │ │ │ ├── custom/
│ │ │ │ │ ├── collections/
│ │ │ │ │ │ └── [collectionId]/
│ │ │ │ │ │ └── details/
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ ├── index-products/
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ └── product-types/
│ │ │ │ │ └── [productTypeId]/
│ │ │ │ │ └── details/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── fashion/
│ │ │ │ │ ├── [id]/
│ │ │ │ │ │ ├── colors/
│ │ │ │ │ │ │ ├── [colorId]/
│ │ │ │ │ │ │ │ ├── restore/
│ │ │ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ ├── restore/
│ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ └── route.ts
│ │ │ │ └── products/
│ │ │ │ └── [id]/
│ │ │ │ └── fashion/
│ │ │ │ └── route.ts
│ │ │ ├── middlewares.ts
│ │ │ └── store/
│ │ │ └── custom/
│ │ │ ├── customer/
│ │ │ │ └── send-welcome-email/
│ │ │ │ └── route.ts
│ │ │ ├── fashion/
│ │ │ │ └── [productHandle]/
│ │ │ │ └── route.ts
│ │ │ ├── product-types/
│ │ │ │ ├── [id]/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── helpers.ts
│ │ │ │ ├── middlewares.ts
│ │ │ │ ├── query-config.ts
│ │ │ │ ├── route.ts
│ │ │ │ └── validators.ts
│ │ │ └── stripe/
│ │ │ ├── get-payment-method/
│ │ │ │ └── [id]/
│ │ │ │ └── route.ts
│ │ │ └── set-payment-method/
│ │ │ └── route.ts
│ │ ├── jobs/
│ │ │ └── README.md
│ │ ├── links/
│ │ │ └── README.md
│ │ ├── modules/
│ │ │ ├── README.md
│ │ │ ├── fashion/
│ │ │ │ ├── index.ts
│ │ │ │ ├── migrations/
│ │ │ │ │ ├── .snapshot-medusa.json
│ │ │ │ │ └── Migration20241002190028.ts
│ │ │ │ ├── models/
│ │ │ │ │ ├── color.ts
│ │ │ │ │ └── material.ts
│ │ │ │ └── service.ts
│ │ │ ├── meilisearch/
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── service.ts
│ │ │ │ └── types.ts
│ │ │ └── resend/
│ │ │ ├── emails/
│ │ │ │ ├── auth-email-confirm.tsx
│ │ │ │ ├── auth-forgot-password.tsx
│ │ │ │ ├── auth-password-reset.tsx
│ │ │ │ ├── components/
│ │ │ │ │ └── EmailLayout.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── order-placed.tsx
│ │ │ │ ├── order-update.tsx
│ │ │ │ └── welcome.tsx
│ │ │ ├── index.ts
│ │ │ └── service.tsx
│ │ ├── scripts/
│ │ │ ├── README.md
│ │ │ ├── index-products.ts
│ │ │ └── seed.ts
│ │ ├── subscribers/
│ │ │ ├── README.md
│ │ │ ├── auth-password-reset-notification.ts
│ │ │ ├── customer-welcome-notification.ts
│ │ │ ├── index-products.ts
│ │ │ └── order-placed-notification.ts
│ │ └── workflows/
│ │ ├── README.md
│ │ ├── emit-customer-welcome-event.ts
│ │ └── index-products.ts
│ └── tsconfig.json
└── storefront/
├── .github/
│ ├── scripts/
│ │ └── medusa-config.js
│ └── workflows/
│ └── test-e2e.yaml
├── .gitignore
├── .prettierrc
├── .yarnrc.yml
├── LICENSE
├── README.md
├── check-env-variables.js
├── e2e/
│ ├── 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
├── eslint.config.cjs
├── next-env.d.ts
├── next-sitemap.js
├── next.config.js
├── package.json
├── playwright.config.ts
├── postcss.config.js
├── src/
│ ├── app/
│ │ ├── [countryCode]/
│ │ │ ├── (checkout)/
│ │ │ │ ├── checkout/
│ │ │ │ │ ├── loading.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ └── not-found.tsx
│ │ │ └── (main)/
│ │ │ ├── about/
│ │ │ │ └── page.tsx
│ │ │ ├── account/
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── loading.tsx
│ │ │ │ ├── my-orders/
│ │ │ │ │ ├── [orderId]/
│ │ │ │ │ │ └── page.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── auth/
│ │ │ │ ├── forgot-password/
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── reset/
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── login/
│ │ │ │ │ ├── loading.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── register/
│ │ │ │ │ ├── loading.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ └── reset-password/
│ │ │ │ └── page.tsx
│ │ │ ├── cart/
│ │ │ │ ├── loading.tsx
│ │ │ │ ├── not-found.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── collections/
│ │ │ │ └── [handle]/
│ │ │ │ └── page.tsx
│ │ │ ├── cookie-policy/
│ │ │ │ └── page.tsx
│ │ │ ├── inspiration/
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── not-found.tsx
│ │ │ ├── order/
│ │ │ │ └── confirmed/
│ │ │ │ └── [id]/
│ │ │ │ ├── loading.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── page.tsx
│ │ │ ├── privacy-policy/
│ │ │ │ └── page.tsx
│ │ │ ├── products/
│ │ │ │ └── [handle]/
│ │ │ │ └── page.tsx
│ │ │ ├── search/
│ │ │ │ └── page.tsx
│ │ │ ├── store/
│ │ │ │ └── page.tsx
│ │ │ └── terms-of-use/
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── not-found.tsx
│ │ └── robots.ts
│ ├── components/
│ │ ├── Button.tsx
│ │ ├── Carousel.tsx
│ │ ├── CartDrawer.tsx
│ │ ├── CartIcon.tsx
│ │ ├── CollectionsSection.tsx
│ │ ├── Dialog.tsx
│ │ ├── Drawer.tsx
│ │ ├── Footer.tsx
│ │ ├── Forms.tsx
│ │ ├── Header.tsx
│ │ ├── HeaderDrawer.tsx
│ │ ├── HeaderWrapper.tsx
│ │ ├── Icon.tsx
│ │ ├── IconCircle.tsx
│ │ ├── InputNumberField.tsx
│ │ ├── Layout.tsx
│ │ ├── Link.tsx
│ │ ├── LocalizedLink.tsx
│ │ ├── NewsletterForm.tsx
│ │ ├── NumberField.tsx
│ │ ├── ProductPageGallery.tsx
│ │ ├── RegionSwitcher.tsx
│ │ ├── SearchField.tsx
│ │ ├── icons/
│ │ │ ├── ArrowLeft.tsx
│ │ │ ├── ArrowRight.tsx
│ │ │ ├── ArrowUpRight.tsx
│ │ │ ├── Calendar.tsx
│ │ │ ├── Case.tsx
│ │ │ ├── Check.tsx
│ │ │ ├── ChevronDown.tsx
│ │ │ ├── ChevronLeft.tsx
│ │ │ ├── ChevronRight.tsx
│ │ │ ├── ChevronUp.tsx
│ │ │ ├── Close.tsx
│ │ │ ├── CreditCard.tsx
│ │ │ ├── Heart.tsx
│ │ │ ├── Info.tsx
│ │ │ ├── Loader.tsx
│ │ │ ├── MapPin.tsx
│ │ │ ├── Menu.tsx
│ │ │ ├── Minus.tsx
│ │ │ ├── Package.tsx
│ │ │ ├── Plus.tsx
│ │ │ ├── Receipt.tsx
│ │ │ ├── Search.tsx
│ │ │ ├── Sliders.tsx
│ │ │ ├── Trash.tsx
│ │ │ ├── Truck.tsx
│ │ │ ├── Undo.tsx
│ │ │ └── User.tsx
│ │ └── ui/
│ │ ├── Checkbox.tsx
│ │ ├── Modal.tsx
│ │ ├── Radio.tsx
│ │ ├── Select.tsx
│ │ ├── Skeleton.tsx
│ │ ├── Slider.tsx
│ │ ├── Tag.tsx
│ │ └── TagList.tsx
│ ├── hooks/
│ │ ├── cart.ts
│ │ ├── country-code.tsx
│ │ ├── customer.ts
│ │ └── store.tsx
│ ├── lib/
│ │ ├── config.ts
│ │ ├── constants.tsx
│ │ ├── data/
│ │ │ ├── cart.ts
│ │ │ ├── categories.ts
│ │ │ ├── collections.ts
│ │ │ ├── cookies.ts
│ │ │ ├── customer.ts
│ │ │ ├── fulfillment.ts
│ │ │ ├── orders.ts
│ │ │ ├── payment.ts
│ │ │ ├── product-types.ts
│ │ │ ├── products.ts
│ │ │ └── regions.ts
│ │ ├── search-client.ts
│ │ ├── util/
│ │ │ ├── collections.ts
│ │ │ ├── compare-addresses.ts
│ │ │ ├── enrich-line-items.ts
│ │ │ ├── env.ts
│ │ │ ├── get-precentage-diff.ts
│ │ │ ├── get-product-price.ts
│ │ │ ├── inventory.ts
│ │ │ ├── isEmpty.ts
│ │ │ ├── medusa-error.ts
│ │ │ ├── money.ts
│ │ │ ├── react-query.tsx
│ │ │ ├── repeat.ts
│ │ │ └── sort-products.ts
│ │ └── webmcp/
│ │ ├── WebMCPProvider.tsx
│ │ ├── is-supported.ts
│ │ ├── register-tools.ts
│ │ ├── tools/
│ │ │ ├── cart.ts
│ │ │ ├── checkout.ts
│ │ │ ├── products-search.ts
│ │ │ └── promotion.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── middleware.ts
│ ├── modules/
│ │ ├── account/
│ │ │ └── components/
│ │ │ ├── AddressMultiple.tsx
│ │ │ ├── AddressSingle.tsx
│ │ │ ├── DefaultBillingAddressSelect.tsx
│ │ │ ├── DefaultShippingAddressSelect.tsx
│ │ │ ├── DeleteAddressButton.tsx
│ │ │ ├── PersonalInfoForm.tsx
│ │ │ ├── RequestPasswordResetButton.tsx
│ │ │ ├── SidebarNav.tsx
│ │ │ ├── SignOutButton.tsx
│ │ │ └── UpsertAddressForm.tsx
│ │ ├── auth/
│ │ │ └── components/
│ │ │ ├── ForgotPasswordForm.tsx
│ │ │ ├── LoginForm.tsx
│ │ │ ├── ResetPasswordForm.tsx
│ │ │ └── SignUpForm.tsx
│ │ ├── cart/
│ │ │ ├── components/
│ │ │ │ ├── cart-totals/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── discount-code/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── empty-cart-message/
│ │ │ │ │ └── index.tsx
│ │ │ │ └── item/
│ │ │ │ └── index.tsx
│ │ │ ├── templates/
│ │ │ │ ├── index.tsx
│ │ │ │ ├── items.tsx
│ │ │ │ └── summary.tsx
│ │ │ └── utils/
│ │ │ └── getCheckoutStep.tsx
│ │ ├── checkout/
│ │ │ ├── components/
│ │ │ │ ├── addresses/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── billing_address/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── checkout-form/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── checkout-summary-wrapper/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── country-select/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── discount-code/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── email/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── error-message/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── mobile-checkout-summary-wrapper/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── payment/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── payment-button/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── payment-card-button/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── payment-container/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── payment-test/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── payment-wrapper/
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ └── stripe-wrapper.tsx
│ │ │ │ ├── review/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── shipping/
│ │ │ │ │ └── index.tsx
│ │ │ │ └── shipping-address/
│ │ │ │ └── index.tsx
│ │ │ └── templates/
│ │ │ ├── checkout-summary/
│ │ │ │ └── index.tsx
│ │ │ └── mobile-checkout-summary/
│ │ │ └── index.tsx
│ │ ├── collections/
│ │ │ └── templates/
│ │ │ └── index.tsx
│ │ ├── common/
│ │ │ ├── components/
│ │ │ │ ├── cart-totals/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── delete-button/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── line-item-unit-price/
│ │ │ │ │ └── index.tsx
│ │ │ │ └── submit-button/
│ │ │ │ └── index.tsx
│ │ │ └── icons/
│ │ │ ├── bancontact.tsx
│ │ │ ├── ideal.tsx
│ │ │ ├── paypal.tsx
│ │ │ ├── placeholder-image.tsx
│ │ │ └── spinner.tsx
│ │ ├── header/
│ │ │ └── components/
│ │ │ └── LoginLink.tsx
│ │ ├── order/
│ │ │ ├── components/
│ │ │ │ ├── OrderTotals.tsx
│ │ │ │ ├── item/
│ │ │ │ │ └── index.tsx
│ │ │ │ └── payment-details/
│ │ │ │ └── index.tsx
│ │ │ └── templates/
│ │ │ └── order-completed-template.tsx
│ │ ├── products/
│ │ │ ├── components/
│ │ │ │ ├── image-gallery/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── product-actions/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── product-preview/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── product-price/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── related-products/
│ │ │ │ │ └── index.tsx
│ │ │ │ └── thumbnail/
│ │ │ │ └── index.tsx
│ │ │ └── templates/
│ │ │ ├── index.tsx
│ │ │ ├── product-actions-wrapper/
│ │ │ │ └── index.tsx
│ │ │ └── product-info/
│ │ │ └── index.tsx
│ │ ├── skeletons/
│ │ │ ├── components/
│ │ │ │ ├── skeleton-button/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── skeleton-cart-item/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── skeleton-cart-totals/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── skeleton-mobile-summary-trigger/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── skeleton-order-summary/
│ │ │ │ │ └── index.tsx
│ │ │ │ └── skeleton-product-preview/
│ │ │ │ └── index.tsx
│ │ │ └── templates/
│ │ │ ├── skeleton-account-page/
│ │ │ │ └── index.tsx
│ │ │ ├── skeleton-cart-page/
│ │ │ │ └── index.tsx
│ │ │ ├── skeleton-checkout-summary/
│ │ │ │ └── index.tsx
│ │ │ ├── skeleton-order-confirmed/
│ │ │ │ └── index.tsx
│ │ │ ├── skeleton-product-grid/
│ │ │ │ └── index.tsx
│ │ │ └── skeleton-related-products/
│ │ │ └── index.tsx
│ │ └── store/
│ │ ├── components/
│ │ │ ├── collections-slider/
│ │ │ │ └── index.tsx
│ │ │ ├── no-results.tsx/
│ │ │ │ └── index.tsx
│ │ │ ├── pagination/
│ │ │ │ └── index.tsx
│ │ │ └── refinement-list/
│ │ │ ├── category-filter/
│ │ │ │ └── index.tsx
│ │ │ ├── collection-filter/
│ │ │ │ └── index.tsx
│ │ │ ├── index.tsx
│ │ │ ├── mobile-filters/
│ │ │ │ └── index.tsx
│ │ │ ├── mobile-sort/
│ │ │ │ └── index.tsx
│ │ │ ├── sort-products/
│ │ │ │ └── index.tsx
│ │ │ └── type-filter/
│ │ │ └── index.tsx
│ │ └── templates/
│ │ ├── index.tsx
│ │ └── paginated-products.tsx
│ ├── styles/
│ │ └── globals.css
│ └── types/
│ └── icon.ts
├── tailwind.config.js
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/workflows/node.js.yml
================================================
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
name: PR test
on:
pull_request_target:
types: [assigned, opened, synchronize, reopened]
branches: [master]
paths: ['storefront/**']
jobs:
lint-storefront:
runs-on: ubuntu-latest
strategy:
matrix:
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
node-version: [20.x, 22.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
cache-dependency-path: 'storefront/yarn.lock'
- run: yarn install --frozen-lockfile
working-directory: storefront
- run: yarn lint
working-directory: storefront
env:
NODE_ENV: production
NEXT_PUBLIC_MEDUSA_BACKEND_URL: ${{ secrets.NEXT_PUBLIC_MEDUSA_BACKEND_URL }}
NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY }}
NEXT_PUBLIC_STRIPE_KEY: ${{ secrets.NEXT_PUBLIC_STRIPE_KEY }}
REVALIDATE_SECRET: ${{ secrets.REVALIDATE_SECRET }}
DISALLOW_ROBOTS: true
NEXT_PUBLIC_DEFAULT_REGION: us
NEXT_PUBLIC_FEATURE_SEARCH_ENABLED: false
NEXT_PUBLIC_BASE_URL: https://fashion-starter.agilo.com
================================================
FILE: .gitignore
================================================
.DS_Store
.agents/
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2024 Agilo
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
<h1 align="center">Fashion E-commerce Starter for Medusa 2.0</h1>
<video src="https://github.com/user-attachments/assets/1afe48e4-5a28-4aee-b4bd-e405701d3cc6" controls="controls" muted="muted" playsinline="playsinline"></video>
<p align="center">
<a href="https://www.figma.com/community/file/1494273775050024009" target="_blank">
<img src="https://img.shields.io/badge/Figma-Design_Template-F24E1E?style=for-the-badge&logo=figma&logoColor=white" alt="Figma Design Template" />
</a>
</p>
The **Fashion E-commerce Starter** is a modern, customizable e-commerce template built with **Medusa 2.0**. Designed around the concept of the sustainable furniture brand **Sofa Society**, this starter showcases the power of new Medusa 2.0 version. With its focus on cutting-edge design, sustainability, and personalization, Sofa Society offers users an elegant shopping experience where they can explore customizable collections, product options, and a streamlined checkout flow.
This starter kit is an ideal solution for developers who need to set up a professional, feature-rich fashion e-commerce store quickly. It comes with a sleek and modern design, customizable collections, an Inspiration page, an About page, and a streamlined checkout process. The storefront is fully responsive and optimized for mobile, tablet, and desktop devices.
<h2>Table of Contents</h2>
- [Features](#features)
- [Roadmap](#roadmap)
- [Screenshots](#screenshots)
- [Prerequisites](#prerequisites)
- [Quickstart](#quickstart)
- [Medusa](#medusa)
- [Storefront](#storefront)
- [Meilisearch](#meilisearch)
## Features
- **Sleek, Modern Design**: The storefront boasts a minimalist, contemporary design that perfectly reflects **Sofa Society's** commitment to modern aesthetics and sustainability.
- **Dynamic Materials and Colors**: Add richness to your product offerings by defining **materials** and **colors** for each product. Colors will be displayed using their corresponding hex codes, and each material can have multiple color options. Customers first select a material, then a color, with dynamic pricing based on their choices.
- **Customizable Collections**: Easily customize the content and images for each collection. Each product page also features images and a CTA for the collection it belongs to, which can be personalized as well, creating a fully branded shopping experience.
- **Premade Inspiration Page**: A beautiful, ready-to-use inspiration page helps customers explore the latest trends and styles, showcasing Sofa Society's furniture in real-world settings.
- **About Page**: Share your brand’s story, values, and commitment to sustainability with a pre-built about page that captures the essence of **Sofa Society**.
- **Streamlined Checkout Flow**: The checkout process is designed to be fast, intuitive, and frictionless, providing a seamless shopping experience for your customers from start to finish.
- **Fully Responsive Design**: Optimized for mobile, tablet, and desktop devices, ensuring a smooth, consistent experience across all platforms.
- **Stripe Integration for Payments**: Accept payments effortlessly by integrating **Stripe**. Simply add your Stripe API key to `medusa/.env` and the publishable key to `storefront/.env` to get started.
- **Full E-commerce Functionality**: The starter includes all the essential e-commerce features you need, including product pages, a shopping cart, a checkout process, and order confirmation.
- **Next.js and Tailwind CSS**: Built with **Next.js** v15 app router and **Tailwind CSS**, the starter is highly performant, customizable, and easy to extend with additional features.
## Roadmap
- [x] **Figma Design Template**: This will enable you to easily customize the design of the storefront to match your brand. [View template](https://www.figma.com/community/file/1494273775050024009).
- [x] **Search**: Integration with Meilisearch for a powerful search experience.
- [x] **404 Page**: Custom 404 page for a better user experience.
- [x] **Account Management**: Allow customers to create accounts, view order history, and manage their personal information.
- [x] **Cart Drawer**: Cart drawer that slides in from the side where customers can view and edit their cart items.
- [x] **Email Templates**: Customizable email templates for order confirmation, shipping updates, and more.
- [x] **Infinite Scroll Pagination**: Improve the product discovery experience with infinite scroll pagination on store and collection pages.
- [x] **Resend Integration**: Integration with Resend for sending transactional emails.
## Screenshots
<details open="open">
<summary><strong style="font-size: 1.15rem">Home</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">About</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Inspiration</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Collection</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Store</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Product</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Cart</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Checkout</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Checkout Review</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Order Confirmation</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Admin - Edit Collection</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Admin - Edit Product Type</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Admin - Materials</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Admin - Colors</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Admin - Edit Color</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Admin - Product</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Admin - Product Missing Color</strong></summary>

</details>
<details>
<summary><strong style="font-size: 1.15rem">Admin - Product Add Missing Color</strong></summary>

</details>
## Prerequisites
- Node >= 20
- Yarn >= 3.5 for Medusa, Yarn v1 for Storefront
- Docker and Docker Compose
- Stripe account (for payments)
- httpie
## Quickstart
```bash
git clone git@github.com:Agilo/fashion-starter.git
```
### Medusa
```bash
cd medusa
# Create the .env file
cp .env.template .env
# Install dependencies
yarn
# Spin up the database and Redis
docker-compose up -d
# Build the project
yarn build
# Run the migrations
yarn medusa db:migrate
# Seed the database
yarn seed
# Create an user
yarn medusa user -e "admin@medusa.local" -p "supersecret"
# Start the development server
yarn dev
```
At this point, you should be able to access the Medusa admin at http://localhost:9000/app with the credentials you just created. After logging in, you should go to http://localhost:9000/app/settings/publishable-api-keys, copy the publishable key, and paste it into the `NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY` env variable in the `storefront/.env.local` file.
### Storefront
```bash
cd storefront
# Create the .env.local file
cp .env.template .env.local
# Install dependencies
yarn
# Start the development server
yarn dev
```
You should now be able to access the storefront at http://localhost:8000.
### Meilisearch
```bash
# Get search api key
http --auth "yoursecretmasterkey" --auth-type bearer GET http://localhost:7700/keys
```
You should go to `storefront/.env.local` file and paste obtained key into the `NEXT_PUBLIC_SEARCH_API_KEY` env variable. Also, go to the `backend/.env` file and paste admin key into `MEILISEARCH_API_KEY`
<a href="https://agilo.com" target="_blank">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/user-attachments/assets/a4429448-a08a-4f5a-8195-2cea1416ca87">
<img src="https://github.com/user-attachments/assets/772994f8-32c6-4b27-832f-2660f833fd78">
</picture>
</a>
================================================
FILE: medusa/.gitignore
================================================
/dist
.env
.DS_Store
/uploads
/node_modules
yarn-error.log
.idea
coverage
!src/**
./tsconfig.tsbuildinfo
package-lock.json
medusa-db.sql
build
.cache
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.medusa
/static
================================================
FILE: medusa/.npmrc
================================================
node-linker=hoisted
================================================
FILE: medusa/.vscode/settings.json
================================================
{
}
================================================
FILE: medusa/.yarnrc.yml
================================================
nodeLinker: node-modules
================================================
FILE: medusa/README.md
================================================
<p align="center">
<a href="https://www.medusajs.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://user-images.githubusercontent.com/59018053/229103275-b5e482bb-4601-46e6-8142-244f531cebdb.svg">
<source media="(prefers-color-scheme: light)" srcset="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
<img alt="Medusa logo" src="https://user-images.githubusercontent.com/59018053/229103726-e5b529a3-9b3f-4970-8a1f-c6af37f087bf.svg">
</picture>
</a>
</p>
<h1 align="center">
Medusa
</h1>
<h4 align="center">
<a href="https://docs.medusajs.com">Documentation</a> |
<a href="https://www.medusajs.com">Website</a>
</h4>
<p align="center">
Building blocks for digital commerce
</p>
<p align="center">
<a href="https://github.com/medusajs/medusa/blob/master/CONTRIBUTING.md">
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs welcome!" />
</a>
<a href="https://www.producthunt.com/posts/medusa"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Day-%23DA552E" alt="Product Hunt"></a>
<a href="https://discord.gg/xpCwq3Kfn8">
<img src="https://img.shields.io/badge/chat-on%20discord-7289DA.svg" alt="Discord Chat" />
</a>
<a href="https://twitter.com/intent/follow?screen_name=medusajs">
<img src="https://img.shields.io/twitter/follow/medusajs.svg?label=Follow%20@medusajs" alt="Follow @medusajs" />
</a>
</p>
## Compatibility
This starter is compatible with versions >= 1.8.0 of `@medusajs/medusa`.
## Getting Started
Visit the [Quickstart Guide](https://docs.medusajs.com/create-medusa-app) to set up a server.
Visit the [Docs](https://docs.medusajs.com/development/backend/prepare-environment) to learn more about our system requirements.
## What is Medusa
Medusa is a set of commerce modules and tools that allow you to build rich, reliable, and performant commerce applications without reinventing core commerce logic. The modules can be customized and used to build advanced ecommerce stores, marketplaces, or any product that needs foundational commerce primitives. All modules are open-source and freely available on npm.
Learn more about [Medusa’s architecture](https://docs.medusajs.com/development/fundamentals/architecture-overview) and [commerce modules](https://docs.medusajs.com/modules/overview) in the Docs.
## Roadmap, Upgrades & Plugins
You can view the planned, started and completed features in the [Roadmap discussion](https://github.com/medusajs/medusa/discussions/categories/roadmap).
Follow the [Upgrade Guides](https://docs.medusajs.com/upgrade-guides/) to keep your Medusa project up-to-date.
Check out all [available Medusa plugins](https://medusajs.com/plugins/).
## Community & Contributions
The community and core team are available in [GitHub Discussions](https://github.com/medusajs/medusa/discussions), where you can ask for support, discuss roadmap, and share ideas.
Join our [Discord server](https://discord.com/invite/medusajs) to meet other community members.
## Other channels
- [GitHub Issues](https://github.com/medusajs/medusa/issues)
- [Twitter](https://twitter.com/medusajs)
- [LinkedIn](https://www.linkedin.com/company/medusajs)
- [Medusa Blog](https://medusajs.com/blog/)
================================================
FILE: medusa/docker-compose.yml
================================================
services:
postgres:
image: postgres:16
ports:
- 5432:5432
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: medusa
volumes:
- medusa-postgres-data:/var/lib/postgresql/data
redis:
image: redis
ports:
- 6379:6379
minio:
image: minio/minio:RELEASE.2024-10-13T13-34-11Z
ports:
- 9090:9000
- 9001:9001
volumes:
- medusa-minio-data:/data
environment:
MINIO_ROOT_USER: medusaminio
MINIO_ROOT_PASSWORD: medusaminio
command: server /data --console-address ":9001"
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
interval: 30s
timeout: 20s
retries: 3
createbuckets:
image: minio/mc:RELEASE.2024-10-08T09-37-26Z
depends_on:
minio:
condition: service_healthy
restart: on-failure
entrypoint: >
/bin/sh -c "
/usr/bin/mc alias set myminio http://minio:9000 medusaminio medusaminio;
/usr/bin/mc mb myminio/medusa;
/usr/bin/mc anonymous set public myminio/medusa;
exit 0;
"
meilisearch:
image: getmeili/meilisearch:v1.12
ports:
- 7700:7700
volumes:
- meili-data:/meili_data
environment:
MEILI_MASTER_KEY: ${MEILISEARCH_MASTER_KEY}
volumes:
medusa-postgres-data:
medusa-minio-data:
meili-data:
================================================
FILE: medusa/instrumentation.js
================================================
// Uncomment this file to enable instrumentation and observability using OpenTelemetry
// Refer to the docs for installation instructions: https://docs.medusajs.com/v2/debugging-and-testing/instrumentation
// const { registerOtel } = require("@medusajs/medusa")
// // If using an exporter other than Zipkin, require it here.
// const { ZipkinExporter } = require('@opentelemetry/exporter-zipkin')
// // If using an exporter other than Zipkin, initialize it here.
// const exporter = new ZipkinExporter({
// serviceName: 'my-medusa-project',
// })
// export function register() {
// registerOtel({
// serviceName: 'medusajs',
// // pass exporter
// exporter,
// instrument: {
// http: true,
// workflows: true,
// remoteQuery: true
// },
// })
// }
================================================
FILE: medusa/integration-tests/http/README.md
================================================
# Integration Tests
The `medusa-test-utils` package provides utility functions to create integration tests for your API routes and workflows.
For example:
```ts
import { medusaIntegrationTestRunner } from "medusa-test-utils"
medusaIntegrationTestRunner({
testSuite: ({ api, getContainer }) => {
describe("Custom endpoints", () => {
describe("GET /store/custom", () => {
it("returns correct message", async () => {
const response = await api.get(
`/store/custom`
)
expect(response.status).toEqual(200)
expect(response.data).toHaveProperty("message")
expect(response.data.message).toEqual("Hello, World!")
})
})
})
}
})
```
Learn more in [this documentation](https://docs.medusajs.com/v2/debugging-and-testing/testing-tools/integration-tests).
================================================
FILE: medusa/integration-tests/http/health.spec.ts
================================================
import { medusaIntegrationTestRunner } from '@medusajs/test-utils';
jest.setTimeout(60 * 1000);
medusaIntegrationTestRunner({
inApp: true,
env: {},
testSuite: ({ api }) => {
describe('Ping', () => {
it('ping the server health endpoint', async () => {
const response = await api.get('/health');
expect(response.status).toEqual(200);
});
});
},
});
================================================
FILE: medusa/jest.config.js
================================================
const { loadEnv } = require('@medusajs/utils')
loadEnv('test', process.cwd())
module.exports = {
transform: {
"^.+\\.[jt]s$": [
"@swc/jest",
{
jsc: {
parser: { syntax: "typescript", decorators: true },
},
},
],
},
testEnvironment: "node",
moduleFileExtensions: ["js", "ts", "json"],
modulePathIgnorePatterns: ["dist/"],
}
if (process.env.TEST_TYPE === "integration:http") {
module.exports.testMatch = ["**/integration-tests/http/*.spec.[jt]s"]
} else if (process.env.TEST_TYPE === "integration:modules") {
module.exports.testMatch = ["**/src/modules/*/__tests__/**/*.[jt]s"]
} else if (process.env.TEST_TYPE === "unit") {
module.exports.testMatch = ["**/src/**/__tests__/**/*.unit.spec.[jt]s"]
}
================================================
FILE: medusa/medusa-config.js
================================================
const { loadEnv, defineConfig } = require('@medusajs/framework/utils');
loadEnv(process.env.NODE_ENV, process.cwd());
module.exports = defineConfig({
admin: {
backendUrl:
process.env.BACKEND_URL ?? 'https://sofa-society-starter.medusajs.app',
storefrontUrl: process.env.STOREFRONT_URL,
},
projectConfig: {
databaseUrl: process.env.DATABASE_URL,
redisUrl: process.env.REDIS_URL,
http: {
storeCors: process.env.STORE_CORS,
adminCors: process.env.ADMIN_CORS,
authCors: process.env.AUTH_CORS,
jwtSecret: process.env.JWT_SECRET || 'supersecret',
cookieSecret: process.env.COOKIE_SECRET || 'supersecret',
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '24h',
},
},
modules: [
{
resolve: '@medusajs/medusa/payment',
options: {
providers: [
{
id: 'stripe',
resolve: '@medusajs/medusa/payment-stripe',
options: {
apiKey: process.env.STRIPE_API_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
},
},
],
},
},
{
resolve: './src/modules/fashion',
},
{
resolve: '@medusajs/medusa/file',
options: {
providers: [
{
resolve: '@medusajs/medusa/file-s3',
id: 's3',
options: {
file_url: process.env.S3_FILE_URL,
access_key_id: process.env.S3_ACCESS_KEY_ID,
secret_access_key: process.env.S3_SECRET_ACCESS_KEY,
region: process.env.S3_REGION,
bucket: process.env.S3_BUCKET,
endpoint: process.env.S3_ENDPOINT,
additional_client_config: {
forcePathStyle:
process.env.S3_FORCE_PATH_STYLE === 'true' ? true : undefined,
},
},
},
],
},
},
{
resolve: '@medusajs/medusa/notification',
options: {
providers: [
{
resolve: './src/modules/resend',
id: 'resend',
options: {
channels: ['email'],
api_key: process.env.RESEND_API_KEY,
from: process.env.RESEND_FROM,
siteTitle: 'SofaSocietyCo.',
companyName: 'Sofa Society',
footerLinks: [
{
url: 'https://agilo.com',
label: 'Agilo',
},
{
url: 'https://www.instagram.com/agiloltd/',
label: 'Instagram',
},
{
url: 'https://www.linkedin.com/company/agilo/',
label: 'LinkedIn',
},
],
},
},
],
},
},
{
resolve: '@medusajs/medusa/event-bus-redis',
options: {
redisUrl: process.env.REDIS_URL,
},
},
{
resolve: '@medusajs/medusa/caching',
options: {
providers: [
{
resolve: '@medusajs/caching-redis',
id: 'caching-redis',
is_default: true,
options: {
redisUrl: process.env.REDIS_URL,
},
},
],
},
},
{
resolve: '@medusajs/medusa/workflow-engine-redis',
options: {
redis: {
redisUrl: process.env.REDIS_URL,
},
},
},
{
resolve: '@medusajs/medusa/locking',
options: {
providers: [
{
resolve: '@medusajs/medusa/locking-redis',
id: 'locking-redis',
is_default: true,
options: {
redisUrl: process.env.REDIS_URL,
},
},
],
},
},
{
resolve: './src/modules/meilisearch',
/**
* @type {import('./src/modules/meilisearch/types').MeiliSearchPluginOptions}
*/
options: {
config: {
host:
process.env.MEILISEARCH_HOST ??
'https://fashion-starter-search.agilo.agency',
apiKey: process.env.MEILISEARCH_API_KEY,
},
settings: {
products: {
indexSettings: {
searchableAttributes: [
'title',
'subtitle',
'description',
'collection',
'categories',
'type',
'tags',
'variants',
'sku',
],
displayedAttributes: [
'id',
'title',
'handle',
'subtitle',
'description',
'is_giftcard',
'status',
'thumbnail',
'collection',
'collection_handle',
'categories',
'categories_handle',
'type',
'tags',
'variants',
'sku',
],
},
primaryKey: 'id',
/**
* @param {import('@medusajs/types').ProductDTO} product
*/
transformer: (product) => {
return {
id: product.id,
title: product.title,
handle: product.handle,
subtitle: product.subtitle,
description: product.description,
is_giftcard: product.is_giftcard,
status: product.status,
thumbnail: product.images?.[0]?.url ?? null,
collection: product.collection.title,
collection_handle: product.collection.handle,
categories:
product.categories?.map((category) => category.name) ?? [],
categories_handle:
product.categories?.map((category) => category.handle) ?? [],
type: product.type?.value,
tags: product.tags.map((tag) => tag.value),
variants: product.variants.map((variant) => variant.title),
sku: product.variants
.filter(
(variant) => typeof variant.sku === 'string' && variant.sku,
)
.map((variant) => variant.sku),
};
},
},
},
},
},
],
plugins: [
{
resolve: '@agilo/medusa-analytics-plugin',
options: {},
},
],
});
================================================
FILE: medusa/package.json
================================================
{
"name": "fashion-starter-medusa",
"version": "2.0.0",
"description": "A starter for Medusa projects.",
"author": "Medusa (https://medusajs.com)",
"license": "MIT",
"keywords": [
"sqlite",
"postgres",
"typescript",
"ecommerce",
"headless",
"medusa"
],
"scripts": {
"build": "medusa build",
"seed": "medusa exec ./src/scripts/seed.ts",
"start": "medusa start",
"dev": "medusa develop",
"emails:dev": "email dev --dir=src/modules/resend/emails",
"test:integration:http": "TEST_TYPE=integration:http NODE_OPTIONS=--experimental-vm-modules jest --silent=false --runInBand --forceExit",
"test:integration:modules": "TEST_TYPE=integration:modules NODE_OPTIONS=--experimental-vm-modules jest --silent --runInBand --forceExit",
"test:unit": "TEST_TYPE=unit NODE_OPTIONS=--experimental-vm-modules jest --silent --runInBand --forceExit"
},
"dependencies": {
"@agilo/medusa-analytics-plugin": "^1.4.0",
"@medusajs/admin-sdk": "2.13.1",
"@medusajs/cli": "2.13.1",
"@medusajs/framework": "2.13.1",
"@medusajs/icons": "2.13.1",
"@medusajs/medusa": "2.13.1",
"@medusajs/types": "2.13.1",
"@medusajs/ui": "4.1.1",
"@react-email/components": "^1.0.7",
"@tanstack/react-query": "5.64.2",
"meilisearch": "0.55.0",
"posthog-node": "^5.24.15",
"react-dropzone": "^15.0.0",
"resend": "^6.9.2"
},
"devDependencies": {
"@medusajs/test-utils": "2.13.1",
"@react-email/preview-server": "^5.2.8",
"@swc/core": "^1.15.11",
"@swc/jest": "^0.2.39",
"@types/jest": "^29.5.14",
"@types/node": "^22.19.11",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"jest": "^29.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-email": "5.2.8",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=20"
},
"packageManager": "yarn@4.7.0"
}
================================================
FILE: medusa/src/admin/README.md
================================================
# Admin Customizations
You can extend the Medusa Admin to add widgets and new pages. Your customizations interact with API routes to provide merchants with custom functionalities.
## Example: Create a Widget
A widget is a React component that can be injected into an existing page in the admin dashboard.
For example, create the file `src/admin/widgets/product-widget.tsx` with the following content:
```tsx title="src/admin/widgets/product-widget.tsx"
import { defineWidgetConfig } from "@medusajs/admin-sdk"
// The widget
const ProductWidget = () => {
return (
<div>
<h2>Product Widget</h2>
</div>
)
}
// The widget's configurations
export const config = defineWidgetConfig({
zone: "product.details.after",
})
export default ProductWidget
```
This inserts a widget with the text “Product Widget” at the end of a product’s details page.
================================================
FILE: medusa/src/admin/components/EditMaterialDrawer.tsx
================================================
import * as React from 'react';
import { z } from 'zod';
import { Button, Drawer } from '@medusajs/ui';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Form } from './Form/Form';
import { InputField } from './Form/InputField';
export const materialFormSchema = z.object({
name: z.string(),
});
export const EditMaterialDrawer: React.FC<{
id: string;
initialValues: z.infer<typeof materialFormSchema>;
children: React.ReactNode;
}> = ({ id, initialValues, children }) => {
const queryClient = useQueryClient();
const [isDrawerOpen, setIsDrawerOpen] = React.useState(false);
const updateMaterialMutation = useMutation({
mutationKey: ['fashion', 'update'],
mutationFn: async (values: z.infer<typeof materialFormSchema>) => {
return fetch(`/admin/fashion/${id}`, {
method: 'POST',
body: JSON.stringify(values),
credentials: 'include',
}).then((res) => res.json());
},
onSuccess: async () => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'fashion',
});
},
});
return (
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<Drawer.Trigger asChild>{children}</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Edit Material</Drawer.Title>
</Drawer.Header>
<Drawer.Body>
<Form
schema={materialFormSchema}
onSubmit={async (values) => {
await updateMaterialMutation.mutateAsync(values);
setIsDrawerOpen(false);
}}
formProps={{
id: `edit-material-${id}-form`,
}}
defaultValues={initialValues}
>
<InputField name="name" label="Name" />
</Form>
</Drawer.Body>
<Drawer.Footer>
<Drawer.Close asChild>
<Button variant="secondary">Cancel</Button>
</Drawer.Close>
<Button
type="submit"
form={`edit-material-${id}-form`}
isLoading={updateMaterialMutation.isPending}
>
Update
</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer>
);
};
================================================
FILE: medusa/src/admin/components/Form/Form.tsx
================================================
import * as React from 'react';
import {
FormProvider,
useForm,
UseFormProps,
DefaultValues,
UseFormReturn,
} from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
export type FormProps<T extends z.ZodType<any, any>> = UseFormProps<
z.infer<T>
> & {
schema: T;
onSubmit: (
values: z.infer<T>,
form: UseFormReturn<z.infer<T>>,
) => void | Promise<void>;
defaultValues?: DefaultValues<z.infer<T>>;
children?: React.ReactNode;
formProps?: Omit<React.ComponentProps<'form'>, 'onSubmit'>;
};
export const Form = <T extends z.ZodType<any, any>>({
schema,
onSubmit,
children,
formProps,
...props
}: FormProps<T>) => {
const form = useForm({
resolver: zodResolver(schema),
...props,
});
const submitHandler = React.useCallback(
(values: z.infer<T>) => {
return onSubmit(values, form);
},
[onSubmit, form],
);
const onFormSubmit: React.FormEventHandler<HTMLFormElement> =
React.useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
form.handleSubmit(submitHandler)(event);
},
[form, submitHandler],
);
return (
<FormProvider {...form}>
<form {...formProps} onSubmit={onFormSubmit}>
<fieldset disabled={form.formState.isSubmitting}>{children}</fieldset>
</form>
</FormProvider>
);
};
================================================
FILE: medusa/src/admin/components/Form/ImageField.tsx
================================================
import { Label, Button, clx } from '@medusajs/ui';
import { DropzoneProps, useDropzone } from 'react-dropzone';
import { useController, useFormContext } from 'react-hook-form';
import { z } from 'zod';
import { useAdminUploadImage } from '../../hooks/images';
export interface ImageFieldProps {
className?: string;
name: string;
label?: string;
dropzoneProps?: Omit<DropzoneProps, 'maxFiles'>;
dropzoneRootClassName?: string;
sizeRecommendation?: React.ReactNode;
isRequired?: boolean;
}
export interface ImageFieldValue {
id: string;
url: string;
}
export const imageFieldSchema = (params?: z.RawCreateParams) =>
z.object(
{
id: z.string(),
url: z.string().url(),
},
params,
);
export const ImageField: React.FC<ImageFieldProps> = ({
className,
name,
label,
dropzoneProps,
dropzoneRootClassName,
sizeRecommendation = '1200 x 1600 (3:4) recommended, up to 10MB each',
isRequired,
}) => {
const form = useFormContext();
const { field, fieldState } = useController<{
__name__: {
id: string;
url: string;
};
}>({ name: name as '__name__' });
const uploadFileMutation = useAdminUploadImage({
onSuccess: (data) => {
field.onChange({
id: data.files[0].id,
url: data.files[0].url,
});
},
onError(error) {
form.setError(name, {
message: error.message,
type: 'upload_error',
});
},
});
const { getRootProps, getInputProps, open } = useDropzone({
accept: {
'image/*': ['.jpg', '.jpeg', '.png'],
},
...dropzoneProps,
maxFiles: 1,
onDropAccepted(files) {
uploadFileMutation.mutate({
files,
});
},
});
return (
<div className={className}>
{typeof label !== 'undefined' && (
<Label htmlFor={name} className="block mb-1">
{label}
{isRequired ? <span className="text-red-primary">*</span> : ''}
</Label>
)}
<div
{...getRootProps({
className: clx(
'inter-base-regular text-grey-50 rounded-rounded border-grey-20 hover:border-violet-60 hover:text-grey-40 flex h-full w-full cursor-pointer select-none flex-col items-center justify-center border-2 border-dashed transition-colors',
dropzoneRootClassName,
),
})}
>
<input {...getInputProps()} id={name} />
{field.value && typeof field.value !== 'string' ? (
<img
src={field.value.url}
className="w-full h-full object-contain rounded-rounded"
/>
) : (
<div className="flex flex-col items-center justify-center">
<p>
<span>
Drop your image here, or{' '}
<span className="text-violet-60">click to browse</span>
</span>
</p>
{sizeRecommendation}
</div>
)}
</div>
{field.value && typeof field.value !== 'string' && (
<div className="mt-2 flex flex-row items-center justify-center gap-2">
<Button
type="button"
variant="secondary"
onClick={() => {
field.onChange(null);
}}
>
Remove
</Button>
<Button
type="button"
variant="secondary"
onClick={() => {
open();
}}
>
Replace
</Button>
</div>
)}
{fieldState.error && (
<div className="text-red-primary text-sm mt-1">
{fieldState.error.message}
</div>
)}
</div>
);
};
================================================
FILE: medusa/src/admin/components/Form/InputField.tsx
================================================
import { Input, Label, clx } from '@medusajs/ui';
import { useController, ControllerRenderProps } from 'react-hook-form';
export interface InputFieldProps {
className?: string;
name: string;
label?: string;
type?: React.ComponentProps<typeof Input>['type'];
labelProps?: React.ComponentProps<typeof Label>;
inputProps?: Omit<
React.ComponentProps<typeof Input>,
'name' | 'id' | 'type' | keyof ControllerRenderProps
>;
isRequired?: boolean;
suffix?: React.ReactNode;
}
export const InputField: React.FC<InputFieldProps> = ({
className,
name,
label,
type,
labelProps,
inputProps,
isRequired,
suffix,
}) => {
const { field, fieldState } = useController<{ __name__: string }, '__name__'>(
{ name: name as '__name__' }
);
const inputEl = (
<Input
{...inputProps}
{...field}
value={field.value ?? ''}
id={name}
type={type}
aria-invalid={Boolean(fieldState.error)}
className={clx(
{
'pr-8':
Boolean(suffix) &&
(inputProps?.size === 'base' || !inputProps?.size),
'pr-7': Boolean(suffix) && inputProps?.size === 'small',
},
inputProps?.className
)}
/>
);
return (
<div className={className}>
{typeof label !== 'undefined' && (
<Label
{...labelProps}
htmlFor={name}
className={clx('block mb-1', labelProps?.className)}
>
{label}
{isRequired ? <span className="text-red-primary">*</span> : ''}
</Label>
)}
{suffix ? (
<div className="relative">
{inputEl}
<div
className={clx(
'absolute bottom-0 right-0 flex items-center justify-center border-l',
{
'h-8 w-8': inputProps?.size === 'base' || !inputProps?.size,
'h-7 w-7': inputProps?.size === 'small',
}
)}
>
<div className="h-fit w-fit rounded-sm outline-none font-normal font-sans txt-medium text-fg-muted dark:text-fg-muted-dark pointer-events-none select-none">
{suffix}
</div>
</div>
</div>
) : (
inputEl
)}
{fieldState.error && (
<div className="text-red-primary text-sm mt-1">
{fieldState.error.message}
</div>
)}
</div>
);
};
================================================
FILE: medusa/src/admin/components/Form/SelectField.tsx
================================================
import { Label, clx, Select } from '@medusajs/ui';
import { useController, ControllerRenderProps } from 'react-hook-form';
export interface SelectFieldProps {
className?: string;
name: string;
label?: string;
labelProps?: React.ComponentProps<typeof Label>;
selectProps?: Omit<
React.ComponentProps<typeof Select>,
'name' | 'id' | keyof ControllerRenderProps
>;
isRequired?: boolean;
children?: React.ReactNode;
}
export const SelectField: React.FC<SelectFieldProps> = ({
className,
name,
label,
labelProps,
selectProps,
isRequired,
children,
}) => {
const { field, fieldState } = useController<{ __name__: string }, '__name__'>(
{ name: name as '__name__' },
);
return (
<div className={className}>
{typeof label !== 'undefined' && (
<Label
{...labelProps}
htmlFor={name}
className={clx('block mb-1', labelProps?.className)}
>
{label}
{isRequired ? <span className="text-red-primary">*</span> : ''}
</Label>
)}
<Select
{...selectProps}
onValueChange={field.onChange}
onOpenChange={(open) => {
if (!open) {
field.onBlur();
}
}}
value={field.value || ''}
name={field.name}
required={isRequired}
>
{children}
</Select>
{fieldState.error && (
<div className="text-red-primary text-sm mt-1">
{fieldState.error.message}
</div>
)}
</div>
);
};
================================================
FILE: medusa/src/admin/components/Form/SubmitButton.tsx
================================================
import { Button } from '@medusajs/ui';
import { useFormState } from 'react-hook-form';
export const SubmitButton: React.FC<React.ComponentProps<typeof Button>> = (
props
) => {
const { isSubmitting } = useFormState();
return (
<Button
{...props}
type="submit"
isLoading={isSubmitting || props.isLoading}
disabled={isSubmitting || props.disabled}
/>
);
};
================================================
FILE: medusa/src/admin/components/Form/TextareaField.tsx
================================================
import { Textarea, Label, clx } from '@medusajs/ui';
import { useController, ControllerRenderProps } from 'react-hook-form';
export interface TextareaFieldProps {
className?: string;
name: string;
label?: string;
labelProps?: React.ComponentProps<typeof Label>;
textareaProps?: Omit<
React.ComponentProps<typeof Textarea>,
'name' | 'id' | 'type' | keyof ControllerRenderProps
>;
isRequired?: boolean;
}
export const TextareaField: React.FC<TextareaFieldProps> = ({
className,
name,
label,
labelProps,
textareaProps,
isRequired,
}) => {
const { field, fieldState } = useController<{ __name__: string }, '__name__'>(
{ name: name as '__name__' },
);
return (
<div className={className}>
{typeof label !== 'undefined' && (
<Label
{...labelProps}
htmlFor={name}
className={clx('block mb-1', labelProps?.className)}
>
{label}
{isRequired ? <span className="text-red-primary">*</span> : ''}
</Label>
)}
<Textarea
{...textareaProps}
{...field}
value={field.value ?? ''}
id={name}
aria-invalid={Boolean(fieldState.error)}
/>
{fieldState.error && (
<div className="text-red-primary text-sm mt-1">
{fieldState.error.message}
</div>
)}
</div>
);
};
================================================
FILE: medusa/src/admin/components/QueryClientProvider.tsx
================================================
import * as React from 'react';
import {
QueryClient,
QueryClientProvider as TanstackQueryClientProvider,
} from '@tanstack/react-query';
const queryClient = new QueryClient();
export const QueryClientProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
return (
<TanstackQueryClientProvider client={queryClient}>
{children}
</TanstackQueryClientProvider>
);
};
export const withQueryClient = <P extends unknown = {}>(
Component: React.ComponentType<P>,
) => {
return (props: P & JSX.IntrinsicAttributes) => (
<QueryClientProvider>
<Component {...props} />
</QueryClientProvider>
);
};
================================================
FILE: medusa/src/admin/hooks/fashion.ts
================================================
import {
useMutation,
UseMutationOptions,
useQueryClient,
} from '@tanstack/react-query';
export const useCreateMaterialMutation = (
options:
| Omit<
UseMutationOptions<
any,
Error,
{
name: string;
},
unknown
>,
'mutationKey' | 'mutationFn'
>
| undefined = undefined,
) => {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['fashion', 'create'],
mutationFn: async (values: { name: string }) => {
return fetch('/admin/fashion', {
method: 'POST',
body: JSON.stringify(values),
credentials: 'include',
}).then((res) => res.json());
},
...options,
onSuccess: async (...args) => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'fashion',
});
if (options?.onSuccess) {
return options.onSuccess(...args);
}
},
});
};
export const useCreateColorMutation = (
material_id: string,
options:
| Omit<
UseMutationOptions<
any,
Error,
{ name: string; hex_code: string },
unknown
>,
'mutationKey' | 'mutationFn'
>
| undefined = undefined,
) => {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['fashion', material_id, 'colors', 'create'],
mutationFn: async (values: { name: string; hex_code: string }) => {
return fetch(`/admin/fashion/${material_id}/colors`, {
method: 'POST',
body: JSON.stringify(values),
credentials: 'include',
}).then((res) => res.json());
},
...options,
onSuccess: async (...args) => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'fashion',
});
if (options?.onSuccess) {
return options.onSuccess(...args);
}
},
});
};
================================================
FILE: medusa/src/admin/hooks/images.ts
================================================
import { HttpTypes } from '@medusajs/framework/types';
import { UseMutationOptions, useMutation } from '@tanstack/react-query';
const getFileBase64EncodedContent = (file: File) => {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(
(reader.result as string).replace('data:', '').replace(/^.+,/, ''),
);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
const createPayload = async (payload: HttpTypes.AdminUploadFile) => {
if (payload instanceof FileList) {
const formData = new FormData();
for (const file of payload) {
formData.append('files', file);
}
return formData;
}
if (payload.files.every((f) => f instanceof File)) {
const formData = new FormData();
for (const file of payload.files) {
formData.append('files', file);
}
return formData;
}
const obj: {
files: {
name: string;
content: string;
}[];
} = {
files: [],
};
for (const file of payload.files) {
if (file instanceof File) {
obj.files.push({
name: file.name,
content: await getFileBase64EncodedContent(file),
});
} else {
obj.files.push(file);
}
}
return JSON.stringify(obj);
};
export const useAdminUploadImage = (
options?: UseMutationOptions<
HttpTypes.AdminFileListResponse,
Error,
HttpTypes.AdminUploadFile
>,
) => {
return useMutation<
HttpTypes.AdminFileListResponse,
Error,
HttpTypes.AdminUploadFile
>({
mutationKey: ['admin-upload-image'],
mutationFn: async (payload) => {
const res = await fetch(`/admin/uploads`, {
method: 'POST',
body: await createPayload(payload),
credentials: 'include',
});
if (!res.ok) {
throw new Error(res.statusText);
}
return res.json();
},
...options,
});
};
================================================
FILE: medusa/src/admin/routes/fashion/[id]/page.tsx
================================================
import * as React from 'react';
import { z } from 'zod';
import { useParams } from 'react-router-dom';
import {
Container,
Heading,
Text,
IconButton,
Table,
Button,
Drawer,
DropdownMenu,
Prompt,
Switch,
Label,
Kbd,
} from '@medusajs/ui';
import {
PencilSquare,
EllipsisHorizontal,
Trash,
ArrowPath,
} from '@medusajs/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import type { MaterialModelType } from '../../../../modules/fashion/models/material';
import { ColorModelType } from '../../../../modules/fashion/models/color';
import { useCreateColorMutation } from '../../../hooks/fashion';
import { Form } from '../../../components/Form/Form';
import { InputField } from '../../../components/Form/InputField';
import { EditMaterialDrawer } from '../../../components/EditMaterialDrawer';
import { withQueryClient } from '../../../components/QueryClientProvider';
const colorFormSchema = z.object({
name: z.string().min(1),
hex_code: z.string().min(7).max(7),
});
const EditColorDrawer: React.FC<{
materialId: string;
id: string;
initialValues: z.infer<typeof colorFormSchema>;
children: React.ReactNode;
}> = ({ materialId, id, initialValues, children }) => {
const queryClient = useQueryClient();
const [isDrawerOpen, setIsDrawerOpen] = React.useState(false);
const updateColorMutation = useMutation({
mutationKey: ['fashion', materialId, 'colors', id, 'update'],
mutationFn: async (values: z.infer<typeof colorFormSchema>) => {
return fetch(`/admin/fashion/${materialId}/colors/${id}`, {
method: 'POST',
body: JSON.stringify(values),
credentials: 'include',
}).then((res) => res.json());
},
onSuccess: async () => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'fashion',
});
},
});
return (
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<Drawer.Trigger asChild>{children}</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Edit Color</Drawer.Title>
</Drawer.Header>
<Drawer.Body>
<Form
schema={colorFormSchema}
onSubmit={async (values) => {
await updateColorMutation.mutateAsync(values);
setIsDrawerOpen(false);
}}
formProps={{
id: `edit-color-${id}-form`,
}}
defaultValues={initialValues}
>
<div className="flex flex-col gap-4">
<InputField name="name" label="Name" />
<InputField
name="hex_code"
label="Hex Code"
type="color"
inputProps={{
className: 'max-w-8',
}}
/>
</div>
</Form>
</Drawer.Body>
<Drawer.Footer>
<Drawer.Close asChild>
<Button variant="secondary">Cancel</Button>
</Drawer.Close>
<Button
type="submit"
form={`edit-color-${id}-form`}
isLoading={updateColorMutation.isPending}
>
Update
</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer>
);
};
const DeleteColorPrompt: React.FC<{
materialId: string;
id: string;
name: string;
children: React.ReactNode;
}> = ({ materialId, name, id, children }) => {
const queryClient = useQueryClient();
const [isPromptOpen, setIsPromptOpen] = React.useState(false);
const deleteColorMutation = useMutation({
mutationKey: ['fashion', materialId, 'colors', id, 'delete'],
mutationFn: async () => {
return fetch(`/admin/fashion/${materialId}/colors/${id}`, {
method: 'DELETE',
credentials: 'include',
}).then((res) => res.json());
},
onSuccess: async () => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'fashion',
});
setIsPromptOpen(false);
},
});
return (
<Prompt open={isPromptOpen} onOpenChange={setIsPromptOpen}>
<Prompt.Trigger asChild>{children}</Prompt.Trigger>
<Prompt.Content>
<Prompt.Header>
<Prompt.Title>Delete {name} color?</Prompt.Title>
<Prompt.Description>
Are you sure you want to delete the color {name}?
</Prompt.Description>
</Prompt.Header>
<Prompt.Footer>
<Prompt.Cancel>Cancel</Prompt.Cancel>
<Prompt.Action
onClick={() => {
deleteColorMutation.mutate();
}}
>
Delete
</Prompt.Action>
</Prompt.Footer>
</Prompt.Content>
</Prompt>
);
};
const RestoreColorPrompt: React.FC<{
materialId: string;
id: string;
name: string;
children: React.ReactNode;
}> = ({ materialId, name, id, children }) => {
const queryClient = useQueryClient();
const [isPromptOpen, setIsPromptOpen] = React.useState(false);
const restoreColorMutation = useMutation({
mutationKey: ['fashion', materialId, 'colors', id, 'restore'],
mutationFn: async () => {
return fetch(`/admin/fashion/${materialId}/colors/${id}/restore`, {
method: 'POST',
credentials: 'include',
}).then((res) => res.json());
},
onSuccess: async () => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'fashion',
});
setIsPromptOpen(false);
},
});
return (
<Prompt
open={isPromptOpen}
onOpenChange={setIsPromptOpen}
variant="confirmation"
>
<Prompt.Trigger asChild>{children}</Prompt.Trigger>
<Prompt.Content>
<Prompt.Header>
<Prompt.Title>Restore {name} color?</Prompt.Title>
<Prompt.Description>
Are you sure you want to restore the color {name}?
</Prompt.Description>
</Prompt.Header>
<Prompt.Footer>
<Prompt.Cancel>Cancel</Prompt.Cancel>
<Prompt.Action
onClick={() => {
restoreColorMutation.mutate();
}}
>
Restore
</Prompt.Action>
</Prompt.Footer>
</Prompt.Content>
</Prompt>
);
};
const MaterialColors: React.FC<{ materialId: string }> = ({ materialId }) => {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page')) || 1;
const setPage = React.useCallback(
(page: number) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('page', page.toString());
return next;
});
},
[setSearchParams]
);
const deleted = searchParams.has('deleted');
const toggleDeleted = React.useCallback(() => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (prev.has('page')) {
next.delete('page');
}
if (!prev.has('deleted')) {
next.set('deleted', '');
} else {
next.delete('deleted');
}
return next;
});
}, [setSearchParams]);
const [isCreateModalOpen, setIsCreateModalOpen] = React.useState(false);
const { data, isLoading, isError, isSuccess } = useQuery({
queryKey: ['fashion', materialId, 'colors', deleted, page],
queryFn: async () => {
return fetch(
`/admin/fashion/${materialId}/colors?page=${page}${
deleted ? '&deleted=true' : ''
}`,
{
credentials: 'include',
}
).then(
(res) =>
res.json() as Promise<{
colors: ColorModelType[];
count: number;
page: number;
last_page: number;
}>
);
},
});
const createColorMutation = useCreateColorMutation(materialId);
return (
<div className="-px-6">
<div className="px-6 flex flex-row gap-6 justify-between items-center mb-4">
<Heading level="h2">Colors</Heading>
<div className="flex flex-row gap-4">
<div className="flex items-center gap-x-2">
<Switch
id="deleted-flag"
checked={deleted}
onClick={() => {
toggleDeleted();
}}
/>
<Label htmlFor="deleted-flag">Show Deleted</Label>
</div>
<Drawer open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<Drawer.Trigger asChild>
<Button variant="secondary" size="small">
Create
</Button>
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Create Color</Drawer.Title>
</Drawer.Header>
<Drawer.Body>
<Form
schema={colorFormSchema}
onSubmit={async (values) => {
await createColorMutation.mutateAsync(values);
setIsCreateModalOpen(false);
}}
formProps={{
id: 'create-color-form',
}}
>
<div className="flex flex-col gap-4">
<InputField name="name" label="Name" />
<InputField
name="hex_code"
label="Hex Code"
type="color"
inputProps={{
className: 'max-w-8',
}}
/>
</div>
</Form>
</Drawer.Body>
<Drawer.Footer>
<Drawer.Close asChild>
<Button variant="secondary">Cancel</Button>
</Drawer.Close>
<Button
type="submit"
form="create-color-form"
isLoading={createColorMutation.isPending}
>
Create
</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer>
</div>
</div>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell>Hex Code</Table.HeaderCell>
<Table.HeaderCell> </Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{isLoading && (
<Table.Row>
{/* @ts-ignore */}
<Table.Cell colSpan={3}>
<Text>Loading...</Text>
</Table.Cell>
</Table.Row>
)}
{isError && (
<Table.Row>
{/* @ts-ignore */}
<Table.Cell colSpan={3}>
<Text>Error loading colors</Text>
</Table.Cell>
</Table.Row>
)}
{isSuccess && data.colors.length === 0 && (
<Table.Row>
{/* @ts-ignore */}
<Table.Cell colSpan={3}>
<Text>No colors found</Text>
</Table.Cell>
</Table.Row>
)}
{isSuccess &&
data.colors.length > 0 &&
data.colors.map((color) => (
<Table.Row key={color.id}>
<Table.Cell>{color.name}</Table.Cell>
<Table.Cell>
<Kbd className="flex flex-row gap-1 items-center font-mono">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: `${color.hex_code}` }}
/>
{color.hex_code}
</Kbd>
</Table.Cell>
<Table.Cell className="text-right">
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton>
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item asChild>
<EditColorDrawer
materialId={materialId}
id={color.id}
initialValues={color}
>
<Button
variant="transparent"
className="flex flex-row gap-2 items-center w-full justify-start"
>
<PencilSquare className="text-fg-subtle dark:text-fg-subtle-dark" />
Edit
</Button>
</EditColorDrawer>
</DropdownMenu.Item>
<DropdownMenu.Separator />
{color.deleted_at ? (
<DropdownMenu.Item asChild>
<RestoreColorPrompt
materialId={materialId}
id={color.id}
name={color.name}
>
<Button
variant="transparent"
className="flex flex-row gap-2 items-center w-full justify-start"
>
<ArrowPath className="text-fg-subtle dark:text-fg-subtle-dark" />
Restore
</Button>
</RestoreColorPrompt>
</DropdownMenu.Item>
) : (
<DropdownMenu.Item asChild>
<DeleteColorPrompt
materialId={materialId}
id={color.id}
name={color.name}
>
<Button
variant="transparent"
className="flex flex-row gap-2 items-center w-full justify-start"
>
<Trash className="text-fg-subtle dark:text-fg-subtle-dark" />
Delete
</Button>
</DeleteColorPrompt>
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
<Table.Pagination
className="pb-0"
count={data?.count || 0}
pageSize={20}
pageIndex={page - 1}
pageCount={data?.last_page ?? 1}
canPreviousPage={page > 1}
canNextPage={page < (data?.last_page ?? 1)}
previousPage={() => setPage(Math.max(1, page - 1))}
nextPage={() => setPage(Math.min(page + 1, data?.last_page ?? 1))}
/>
</div>
);
};
const MaterialPage = () => {
const { id } = useParams();
const { data, isLoading, isError, isSuccess } = useQuery({
queryKey: ['fashion', id],
queryFn: async () => {
const res = await fetch(`/admin/fashion/${id}`, {
credentials: 'include',
});
return res.json() as Promise<MaterialModelType>;
},
});
if (!id) {
return null;
}
return (
<Container className="px-0">
{isLoading && <Text>Loading...</Text>}
{isError && <Text>Error loading material</Text>}
{isSuccess && (
<>
<div className="px-6 flex flex-row gap-6 justify-between items-center mb-4">
<div className="flex flex-row gap-3">
<Heading level="h2">{data?.name}</Heading>
<EditMaterialDrawer id={id} initialValues={data}>
<IconButton size="xsmall" variant="transparent">
<PencilSquare />
</IconButton>
</EditMaterialDrawer>
</div>
</div>
</>
)}
<hr className="mb-6" />
<MaterialColors materialId={id} />
</Container>
);
};
export default withQueryClient(MaterialPage);
================================================
FILE: medusa/src/admin/routes/fashion/page.tsx
================================================
import * as React from 'react';
import { defineRouteConfig } from '@medusajs/admin-sdk';
import {
Swatch,
PencilSquare,
EllipsisHorizontal,
Trash,
ArrowPath,
} from '@medusajs/icons';
import {
Container,
Heading,
Table,
Button,
IconButton,
Text,
Drawer,
DropdownMenu,
Prompt,
Switch,
Label,
} from '@medusajs/ui';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams, Link } from 'react-router-dom';
import { MaterialModelType } from '../../../modules/fashion/models/material';
import { useCreateMaterialMutation } from '../../hooks/fashion';
import { Form } from '../../components/Form/Form';
import { InputField } from '../../components/Form/InputField';
import {
EditMaterialDrawer,
materialFormSchema,
} from '../../components/EditMaterialDrawer';
import { withQueryClient } from '../../components/QueryClientProvider';
const DeleteMaterialPrompt: React.FC<{
id: string;
name: string;
children: React.ReactNode;
}> = ({ id, name, children }) => {
const queryClient = useQueryClient();
const [isPromptOpen, setIsPromptOpen] = React.useState(false);
const deleteMaterialMutation = useMutation({
mutationKey: ['fashion', id, 'delete'],
mutationFn: async () => {
return fetch(`/admin/fashion/${id}`, {
method: 'DELETE',
credentials: 'include',
}).then((res) => res.json());
},
onSuccess: async () => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'fashion',
});
setIsPromptOpen(false);
},
});
return (
<Prompt open={isPromptOpen} onOpenChange={setIsPromptOpen}>
<Prompt.Trigger asChild>{children}</Prompt.Trigger>
<Prompt.Content>
<Prompt.Header>
<Prompt.Title>Delete {name} material?</Prompt.Title>
<Prompt.Description>
Are you sure you want to delete the material {name}?
</Prompt.Description>
</Prompt.Header>
<Prompt.Footer>
<Prompt.Cancel>Cancel</Prompt.Cancel>
<Prompt.Action
onClick={() => {
deleteMaterialMutation.mutate();
}}
>
Delete
</Prompt.Action>
</Prompt.Footer>
</Prompt.Content>
</Prompt>
);
};
const RestoreMaterialPrompt: React.FC<{
id: string;
name: string;
children: React.ReactNode;
}> = ({ id, name, children }) => {
const queryClient = useQueryClient();
const [isPromptOpen, setIsPromptOpen] = React.useState(false);
const restoreMaterialMutation = useMutation({
mutationKey: ['fashion', id, 'restore'],
mutationFn: async () => {
return fetch(`/admin/fashion/${id}/restore`, {
method: 'POST',
credentials: 'include',
}).then((res) => res.json());
},
onSuccess: async () => {
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'fashion',
});
setIsPromptOpen(false);
},
});
return (
<Prompt open={isPromptOpen} onOpenChange={setIsPromptOpen}>
<Prompt.Trigger asChild>{children}</Prompt.Trigger>
<Prompt.Content>
<Prompt.Header>
<Prompt.Title>Restore {name} material?</Prompt.Title>
<Prompt.Description>
Are you sure you want to restore the material {name}?
</Prompt.Description>
</Prompt.Header>
<Prompt.Footer>
<Prompt.Cancel>Cancel</Prompt.Cancel>
<Prompt.Action
onClick={() => {
restoreMaterialMutation.mutate();
}}
>
Restore
</Prompt.Action>
</Prompt.Footer>
</Prompt.Content>
</Prompt>
);
};
const FashionPage = () => {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page')) || 1;
const setPage = React.useCallback(
(page: number) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('page', page.toString());
return next;
});
},
[setSearchParams]
);
const deleted = searchParams.has('deleted');
const toggleDeleted = React.useCallback(() => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (prev.has('page')) {
next.delete('page');
}
if (!prev.has('deleted')) {
next.set('deleted', '');
} else {
next.delete('deleted');
}
return next;
});
}, [setSearchParams]);
const [isCreateModalOpen, setIsCreateModalOpen] = React.useState(false);
const { data, isLoading, isError, isSuccess } = useQuery({
queryKey: ['fashion', deleted, page],
queryFn: async () => {
return fetch(
`/admin/fashion?page=${page}${deleted ? '&deleted=true' : ''}`,
{
credentials: 'include',
}
).then(
(res) =>
res.json() as Promise<{
materials: MaterialModelType[];
count: number;
page: number;
last_page: number;
}>
);
},
});
const createMaterialMutation = useCreateMaterialMutation();
return (
<Container className="px-0">
<div className="px-6 flex flex-row gap-6 justify-between items-center mb-4">
<Heading level="h2">Materials</Heading>
<div className="flex flex-row gap-4">
<div className="flex items-center gap-x-2">
<Switch
id="deleted-flag"
checked={deleted}
onClick={() => {
toggleDeleted();
}}
/>
<Label htmlFor="deleted-flag">Show Deleted</Label>
</div>
<Drawer open={isCreateModalOpen} onOpenChange={setIsCreateModalOpen}>
<Drawer.Trigger asChild>
<Button variant="secondary" size="small">
Create
</Button>
</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Create Material</Drawer.Title>
</Drawer.Header>
<Drawer.Body>
<Form
schema={materialFormSchema}
onSubmit={async (values) => {
await createMaterialMutation.mutateAsync(values);
setIsCreateModalOpen(false);
}}
formProps={{
id: 'create-material-form',
}}
>
<InputField name="name" label="Name" />
</Form>
</Drawer.Body>
<Drawer.Footer>
<Drawer.Close asChild>
<Button variant="secondary">Cancel</Button>
</Drawer.Close>
<Button
type="submit"
form="create-material-form"
isLoading={createMaterialMutation.isPending}
>
Create
</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer>
</div>
</div>
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Name</Table.HeaderCell>
<Table.HeaderCell> </Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{isLoading && (
<Table.Row>
{/* @ts-ignore */}
<Table.Cell colSpan={2}>
<Text>Loading...</Text>
</Table.Cell>
</Table.Row>
)}
{isError && (
<Table.Row>
{/* @ts-ignore */}
<Table.Cell colSpan={2}>
<Text>Error loading materials</Text>
</Table.Cell>
</Table.Row>
)}
{isSuccess && data.materials.length === 0 && (
<Table.Row>
{/* @ts-ignore */}
<Table.Cell colSpan={2}>
<Text>No materials found</Text>
</Table.Cell>
</Table.Row>
)}
{isSuccess &&
data.materials.length > 0 &&
data.materials.map((material) => (
<Table.Row key={material.id}>
<Table.Cell>
<Link to={`/fashion/${material.id}`}>{material.name}</Link>
</Table.Cell>
<Table.Cell className="text-right">
<DropdownMenu>
<DropdownMenu.Trigger asChild>
<IconButton>
<EllipsisHorizontal />
</IconButton>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item asChild>
<EditMaterialDrawer
id={material.id}
initialValues={material}
>
<Button
variant="transparent"
className="flex flex-row gap-2 items-center w-full justify-start"
>
<PencilSquare className="text-fg-subtle dark:text-fg-subtle-dark" />
Edit
</Button>
</EditMaterialDrawer>
</DropdownMenu.Item>
<DropdownMenu.Separator />
{material.deleted_at ? (
<DropdownMenu.Item asChild>
<RestoreMaterialPrompt
id={material.id}
name={material.name}
>
<Button
variant="transparent"
className="flex flex-row gap-2 items-center w-full justify-start"
>
<ArrowPath className="text-fg-subtle dark:text-fg-subtle-dark" />
Restore
</Button>
</RestoreMaterialPrompt>
</DropdownMenu.Item>
) : (
<DropdownMenu.Item asChild>
<DeleteMaterialPrompt
id={material.id}
name={material.name}
>
<Button
variant="transparent"
className="flex flex-row gap-2 items-center w-full justify-start"
>
<Trash className="text-fg-subtle dark:text-fg-subtle-dark" />
Delete
</Button>
</DeleteMaterialPrompt>
</DropdownMenu.Item>
)}
</DropdownMenu.Content>
</DropdownMenu>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
<Table.Pagination
className="pb-0"
count={data?.count || 0}
pageSize={20}
pageIndex={page - 1}
pageCount={data?.last_page ?? 1}
canPreviousPage={page > 1}
canNextPage={page < (data?.last_page ?? 1)}
previousPage={() => setPage(Math.max(1, page - 1))}
nextPage={() => setPage(Math.min(page + 1, data?.last_page ?? 1))}
/>
</Container>
);
};
export default withQueryClient(FashionPage);
export const config = defineRouteConfig({
label: 'Materials & Colors',
icon: Swatch,
});
================================================
FILE: medusa/src/admin/tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["."]
}
================================================
FILE: medusa/src/admin/widgets/collection-details.tsx
================================================
import * as React from 'react';
import { defineWidgetConfig } from '@medusajs/admin-sdk';
import { DetailWidgetProps, AdminCollection } from '@medusajs/framework/types';
import { Container, Heading, Button, Drawer, Text } from '@medusajs/ui';
import { PencilSquare } from '@medusajs/icons';
import { z } from 'zod';
import { ImageField, imageFieldSchema } from '../components/Form/ImageField';
import { Form } from '../components/Form/Form';
import { TextareaField } from '../components/Form/TextareaField';
import { InputField } from '../components/Form/InputField';
const detailsFormSchema = z.object({
image: imageFieldSchema().optional(),
description: z.string().optional(),
collection_page_image: imageFieldSchema().optional(),
collection_page_heading: z.string().optional(),
collection_page_content: z.string().optional(),
product_page_heading: z.string().optional(),
product_page_image: imageFieldSchema().optional(),
product_page_wide_image: imageFieldSchema().optional(),
product_page_cta_image: imageFieldSchema().optional(),
product_page_cta_heading: z.string().optional(),
product_page_cta_link: z.string().optional(),
});
const UpdateDetailsDrawer: React.FC<{
children: React.ReactNode;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
id: string;
title: React.ReactNode;
initialValue: z.infer<typeof detailsFormSchema>;
onSave: (values: z.infer<typeof detailsFormSchema>) => void;
}> = ({ children, isOpen, onOpenChange, id, title, initialValue, onSave }) => {
return (
<Drawer open={isOpen} onOpenChange={onOpenChange}>
<Drawer.Trigger asChild>{children}</Drawer.Trigger>
<Drawer.Content className="max-h-full">
<Drawer.Header>
<Drawer.Title>{title}</Drawer.Title>
</Drawer.Header>
<Drawer.Body className="p-4 overflow-auto">
<Form
schema={detailsFormSchema}
onSubmit={async (values) => {
await fetch(`/admin/custom/collections/${id}/details`, {
method: 'POST',
body: JSON.stringify(values),
credentials: 'include',
});
onSave(values);
}}
defaultValues={initialValue}
formProps={{
id: `edit-collection-${id}-fields`,
}}
>
<div className="flex flex-col gap-4">
<ImageField
name="image"
label="Image"
dropzoneRootClassName="h-60"
/>
<TextareaField name="description" label="Description" />
<ImageField
name="collection_page_image"
label="Collection page image"
dropzoneRootClassName="h-60"
/>
<InputField
name="collection_page_heading"
label="Collection page heading"
/>
<TextareaField
name="collection_page_content"
label="Collection page content"
/>
<InputField
name="product_page_heading"
label="Product page heading"
/>
<ImageField
name="product_page_image"
label="Product page image"
dropzoneRootClassName="h-60"
/>
<ImageField
name="product_page_wide_image"
label="Product page wide image"
dropzoneRootClassName="h-60"
/>
<ImageField
name="product_page_cta_image"
label="Product page CTA image"
dropzoneRootClassName="h-60"
/>
<InputField
name="product_page_cta_heading"
label="Product page CTA heading"
/>
<InputField
name="product_page_cta_link"
label="Product page CTA link label"
/>
</div>
</Form>
</Drawer.Body>
<Drawer.Footer>
<Drawer.Close asChild>
<Button variant="secondary">Cancel</Button>
</Drawer.Close>
<Button type="submit" form={`edit-collection-${id}-fields`}>
Save
</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer>
);
};
const CollectionDetailsWidget = ({
data,
}: DetailWidgetProps<AdminCollection>) => {
const [isEditModalOpen, setIsModalOpen] = React.useState(false);
const [details, setDetails] = React.useState<z.infer<
typeof detailsFormSchema
> | null>(null);
React.useEffect(() => {
fetch(`/admin/custom/collections/${data.id}/details`, {
credentials: 'include',
})
.then((res) => res.json())
.then((json) => {
setDetails(json);
})
.catch((e) => {
console.error(e);
});
}, [data.id]);
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>Details</Heading>
{details !== null && (
<UpdateDetailsDrawer
isOpen={isEditModalOpen}
onOpenChange={setIsModalOpen}
title="Update collection details"
id={data.id}
initialValue={details}
onSave={(value) => {
setDetails(value);
setIsModalOpen(false);
}}
>
<Button
variant="transparent"
size="small"
className="text-fg-muted dark:text-fg-muted-dark hover:text-fg-subtle dark:hover:text-fg-subtle-dark"
onClick={(event) => {
event.preventDefault();
setIsModalOpen(true);
}}
>
<PencilSquare /> Edit
</Button>
</UpdateDetailsDrawer>
)}
</div>
<div className="text-fg-subtle dark:text-fg-subtle-dark grid grid-cols-2 items-center px-6 py-4">
{details === null ? (
<Text>Loading...</Text>
) : (
<div className="flex flex-col gap-2">
{typeof details.image?.url === 'string' && (
<div>
<img
src={details.image.url}
className="max-h-60 max-w-none w-auto"
/>
</div>
)}
{(details.description?.length ?? 0) > 0 && (
<Text>{details.description}</Text>
)}
{typeof details.image?.url !== 'string' && !details.description && (
<Text>No details available</Text>
)}
<Heading>Collection Page</Heading>
{typeof details.collection_page_image?.url === 'string' && (
<div>
<img
src={details.collection_page_image.url}
className="max-h-60 max-w-none w-auto"
/>
</div>
)}
{(details.collection_page_heading?.length ?? 0) > 0 && (
<Text>{details.collection_page_heading}</Text>
)}
{(details.collection_page_content?.length ?? 0) > 0 && (
<Text>{details.collection_page_content}</Text>
)}
{typeof details.collection_page_image?.url !== 'string' &&
!details.collection_page_heading &&
!details.collection_page_content && (
<Text>Collection page details not entered</Text>
)}
<Heading>Product Page</Heading>
{typeof details.product_page_heading?.length === 'string' && (
<Text>{details.product_page_heading}</Text>
)}
{typeof details.product_page_image?.url === 'string' && (
<div>
<img
src={details.product_page_image.url}
className="max-h-60 max-w-none w-auto"
/>
</div>
)}
{typeof details.product_page_wide_image?.url === 'string' && (
<div>
<img
src={details.product_page_wide_image.url}
className="max-h-60 max-w-none w-auto"
/>
</div>
)}
{typeof details.product_page_cta_image?.url === 'string' && (
<div>
<img
src={details.product_page_cta_image.url}
className="max-h-60 max-w-none w-auto"
/>
</div>
)}
{(details.product_page_cta_heading?.length ?? 0) > 0 && (
<Text>{details.product_page_cta_heading}</Text>
)}
{(details.product_page_cta_link?.length ?? 0) > 0 && (
<Text>{details.product_page_cta_link}</Text>
)}
{typeof details.product_page_heading?.length !== 'string' &&
typeof details.product_page_image?.url !== 'string' &&
typeof details.product_page_wide_image?.url !== 'string' &&
typeof details.product_page_cta_image?.url !== 'string' &&
!details.product_page_cta_heading &&
!details.product_page_cta_link && (
<Text>Product page details not entered</Text>
)}
</div>
)}
</div>
</Container>
);
};
export const config = defineWidgetConfig({
zone: 'product_collection.details.after',
});
export default CollectionDetailsWidget;
================================================
FILE: medusa/src/admin/widgets/product-fashion.tsx
================================================
import * as React from 'react';
import { defineWidgetConfig } from '@medusajs/admin-sdk';
import { DetailWidgetProps, AdminProduct } from '@medusajs/framework/types';
import {
Container,
Heading,
Text,
Button,
Drawer,
IconButton,
} from '@medusajs/ui';
import { ArrowPath, PlusMini } from '@medusajs/icons';
import { z } from 'zod';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { MaterialModelType } from '../../modules/fashion/models/material';
import { Form } from '../components/Form/Form';
import { withQueryClient } from '../components/QueryClientProvider';
import {
useCreateColorMutation,
useCreateMaterialMutation,
} from '../hooks/fashion';
import { InputField } from '../components/Form/InputField';
// const SelectColorField: React.FC<{
// name: string;
// }> = ({ name }) => {
// const materialsQuery = useInfiniteQuery({
// queryKey: ['fashion'],
// queryFn: async ({ pageParam = 1, signal }) => {
// const res = await fetch(`/admin/fashion?page=${pageParam}`, {
// credentials: 'include',
// signal,
// });
// return res.json() as Promise<{
// materials: MaterialModelType[];
// count: number;
// page: number;
// last_page: number;
// }>;
// },
// initialPageParam: 1,
// getNextPageParam: (lastPage) => {
// return lastPage.page < lastPage.last_page ? lastPage.page + 1 : undefined;
// },
// getPreviousPageParam: (firstPage) => {
// return firstPage.page > 1 ? firstPage.page - 1 : undefined;
// },
// });
// return (
// <SelectField name={name}>
// <Select.Trigger>
// <Select.Value placeholder="Select color" />
// </Select.Trigger>
// <Select.Content>
// {materialsQuery.isSuccess &&
// materialsQuery.data.pages.map((materialsPageData) =>
// materialsPageData.materials.map((material) => (
// <Select.Group key={material.id}>
// <Select.Label>{material.name}</Select.Label>
// {material.colors.map((color) => (
// <Select.Item key={color.id} value={color.id}>
// {color.name}
// </Select.Item>
// ))}
// </Select.Group>
// )),
// )}
// {materialsQuery.isSuccess && materialsQuery.hasNextPage && (
// <Select.Item
// key={'load-more'}
// value="load-more"
// onClick={(event) => {
// event.preventDefault();
// if (materialsQuery.isFetchingNextPage) {
// return;
// }
// materialsQuery.fetchNextPage();
// }}
// >
// {materialsQuery.isFetchingNextPage ? 'Loading...' : 'Load more'}
// </Select.Item>
// )}
// </Select.Content>
// </SelectField>
// );
// };
const addColorFormSchema = z.object({
name: z.string().min(1),
hex_code: z.string().min(7).max(7),
});
const AddColorDrawer: React.FC<{
materialId: string;
name: string;
children: React.ReactNode;
}> = ({ materialId, name, children }) => {
const queryClient = useQueryClient();
const [isDrawerOpen, setIsDrawerOpen] = React.useState(false);
const createColorMutation = useCreateColorMutation(materialId, {
onSuccess: async () => {
await queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey.length >= 3 &&
query.queryKey[0] === 'product' &&
query.queryKey[2] === 'fashion',
});
setIsDrawerOpen(false);
},
});
return (
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<Drawer.Trigger asChild>{children}</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>Add new color</Drawer.Title>
</Drawer.Header>
<Drawer.Body className="p-4">
<Form
schema={addColorFormSchema}
onSubmit={async (values) => {
createColorMutation.mutate(values);
}}
defaultValues={{
name,
}}
formProps={{
id: `material-${materialId}-add-color-${name
.toLowerCase()
.replace(/[^\w]/g, '-')}`,
}}
>
<div className="flex flex-col gap-4">
<fieldset disabled>
<InputField name="name" label="Name" />
</fieldset>
<InputField
name="hex_code"
label="Hex code"
type="color"
inputProps={{
className: 'max-w-8',
}}
/>
</div>
</Form>
</Drawer.Body>
<Drawer.Footer>
<Drawer.Close asChild>
<Button variant="secondary">Cancel</Button>
</Drawer.Close>
<Button
type="submit"
form={`material-${materialId}-add-color-${name
.toLowerCase()
.replace(/[^\w]/g, '-')}`}
isLoading={createColorMutation.isPending}
disabled={createColorMutation.isPending}
>
Save
</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer>
);
};
const ProductFashionWidget = withQueryClient(
({ data }: DetailWidgetProps<AdminProduct>) => {
const productFashion = useQuery({
queryKey: ['product', data.id, 'fashion'],
queryFn: async ({ signal }) => {
const res = await fetch(`/admin/products/${data.id}/fashion`, {
credentials: 'include',
signal,
});
return res.json() as Promise<{
missing_materials: string[];
materials: (MaterialModelType & { missing_colors: string[] })[];
}>;
},
});
const createMaterialMutation = useCreateMaterialMutation({
onSuccess: () => {
productFashion.refetch();
},
});
const materialsData = [
...(productFashion.data?.missing_materials ?? []),
...(productFashion.data?.materials ?? []),
].sort((a, b) => {
const aName = typeof a === 'string' ? a : a.name;
const bName = typeof b === 'string' ? b : b.name;
return aName.localeCompare(bName);
});
return (
<Container className="divide-y p-0">
<div className="flex flex-row items-center justify-between px-6 py-4 gap-6">
<Heading>Materials & Colors</Heading>
<IconButton
variant="transparent"
className="text-fg-muted dark:text-fg-muted-dark hover:text-fg-subtle dark:hover:text-fg-subtle-dark"
onClick={(event) => {
event.preventDefault();
productFashion.refetch();
}}
disabled={productFashion.isFetching}
isLoading={productFashion.isFetching}
>
<ArrowPath />
</IconButton>
</div>
<div className="text-fg-subtle dark:text-fg-subtle-dark px-6 py-4">
{productFashion.isLoading ? (
<Text>Loading...</Text>
) : productFashion.isError ? (
<Text>Error loading product materials</Text>
) : productFashion.isSuccess &&
productFashion.data &&
!materialsData.length ? (
<Text>No product variants with Material option</Text>
) : productFashion.isSuccess && productFashion.data ? (
<div className="flex flex-col gap-8">
{materialsData.map((material) => (
<div
key={typeof material === 'string' ? material : material.id}
className="flex flex-col gap-1"
>
<Text>
<strong
className={
typeof material === 'string'
? 'border-b border-dashed border-button-danger dark:border-button-danger-dark'
: undefined
}
>
{typeof material === 'string' ? material : material.name}
</strong>
</Text>
{typeof material === 'string' ? (
<Button
variant="secondary"
onClick={(event) => {
event.preventDefault();
createMaterialMutation.mutate({
name: material,
});
}}
>
Create material
</Button>
) : (
<div className="flex flex-row gap-4">
{material.colors.map((color) => (
<div
key={color.id}
className="flex flex-col items-center gap-1"
>
<div
style={{ backgroundColor: color.hex_code }}
className="w-10 h-10 border-2 border-grayscale-40 rounded-full"
/>
<Text>{color.name}</Text>
</div>
))}
{material.missing_colors.map((color) => (
<div
key={color}
className="flex flex-col items-center gap-1"
>
<AddColorDrawer materialId={material.id} name={color}>
<IconButton
variant="transparent"
className="w-10 h-10 bg-grayscale-20 border-2 border-dashed border-button-danger dark:border-button-danger-dark rounded-full"
>
<PlusMini />
</IconButton>
</AddColorDrawer>
{/* <div className="w-10 h-10 bg-grayscale-20 border-2 border-dashed border-button-danger dark:border-button-danger-dark rounded-full" /> */}
<Text>{color}</Text>
</div>
))}
</div>
)}
</div>
))}
</div>
) : (
<Text>No fashion details set</Text>
)}
</div>
</Container>
);
}
);
export const config = defineWidgetConfig({
zone: 'product.details.side.before',
});
export default ProductFashionWidget;
================================================
FILE: medusa/src/admin/widgets/product-type-details.tsx
================================================
import * as React from 'react';
import { defineWidgetConfig } from '@medusajs/admin-sdk';
import { DetailWidgetProps, AdminCollection } from '@medusajs/framework/types';
import { Container, Heading, Button, Drawer, Text } from '@medusajs/ui';
import { PencilSquare } from '@medusajs/icons';
import { z } from 'zod';
import { ImageField, imageFieldSchema } from '../components/Form/ImageField';
import { Form } from '../components/Form/Form';
const detailsFormSchema = z.object({
image: imageFieldSchema().optional(),
});
const UpdateDetailsDrawer: React.FC<{
children: React.ReactNode;
isOpen: boolean;
onOpenChange: (open: boolean) => void;
id: string;
title: React.ReactNode;
initialValue: z.infer<typeof detailsFormSchema>;
onSave: (values: z.infer<typeof detailsFormSchema>) => void;
}> = ({ children, isOpen, onOpenChange, id, title, initialValue, onSave }) => {
return (
<Drawer open={isOpen} onOpenChange={onOpenChange}>
<Drawer.Trigger asChild>{children}</Drawer.Trigger>
<Drawer.Content>
<Drawer.Header>
<Drawer.Title>{title}</Drawer.Title>
</Drawer.Header>
<Drawer.Body className="p-4">
<Form
schema={detailsFormSchema}
onSubmit={async (values) => {
await fetch(`/admin/custom/product-types/${id}/details`, {
method: 'POST',
body: JSON.stringify(values),
credentials: 'include',
});
onSave(values);
}}
defaultValues={initialValue}
formProps={{
id: `edit-product-type-${id}-fields`,
}}
>
<div className="flex flex-col gap-4">
<ImageField
name="image"
label="Image"
dropzoneRootClassName="h-60"
/>
</div>
</Form>
</Drawer.Body>
<Drawer.Footer>
<Drawer.Close asChild>
<Button variant="secondary">Cancel</Button>
</Drawer.Close>
<Button type="submit" form={`edit-product-type-${id}-fields`}>
Save
</Button>
</Drawer.Footer>
</Drawer.Content>
</Drawer>
);
};
const ProductTypeDetailsWidget = ({
data,
}: DetailWidgetProps<AdminCollection>) => {
const [isEditModalOpen, setIsModalOpen] = React.useState(false);
const [details, setDetails] = React.useState<z.infer<
typeof detailsFormSchema
> | null>(null);
React.useEffect(() => {
fetch(`/admin/custom/product-types/${data.id}/details`, {
credentials: 'include',
})
.then((res) => res.json())
.then((json) => {
setDetails(json);
})
.catch((e) => {
console.error(e);
});
}, [data.id]);
return (
<Container className="divide-y p-0">
<div className="flex items-center justify-between px-6 py-4">
<Heading>Details</Heading>
{details !== null && (
<UpdateDetailsDrawer
isOpen={isEditModalOpen}
onOpenChange={setIsModalOpen}
title="Update description"
id={data.id}
initialValue={details}
onSave={(value) => {
setDetails(value);
setIsModalOpen(false);
}}
>
<Button
variant="transparent"
size="small"
className="text-fg-muted dark:text-fg-muted-dark hover:text-fg-subtle dark:hover:text-fg-subtle-dark"
onClick={(event) => {
event.preventDefault();
setIsModalOpen(true);
}}
>
<PencilSquare /> Edit
</Button>
</UpdateDetailsDrawer>
)}
</div>
<div className="text-fg-subtle dark:text-fg-subtle-dark grid grid-cols-2 items-center px-6 py-4">
{details === null ? (
<Text>Loading...</Text>
) : (
<div className="flex flex-col gap-2">
{typeof details.image?.url === 'string' ? (
<div>
<img
src={details.image.url}
className="max-h-60 max-w-none w-auto"
/>
</div>
) : (
<Text>No image</Text>
)}
</div>
)}
</div>
</Container>
);
};
export const config = defineWidgetConfig({
zone: 'product_type.details.after',
});
export default ProductTypeDetailsWidget;
================================================
FILE: medusa/src/api/README.md
================================================
# Custom API Routes
An API Route is a REST API endpoint.
An API Route is created in a TypeScript or JavaScript file under the `/src/api` directory of your Medusa application. The file’s name must be `route.ts` or `route.js`.
For example, to create a `GET` API Route at `/store/hello-world`, create the file `src/api/store/hello-world/route.ts` with the following content:
```ts
import type { MedusaRequest, MedusaResponse } from "@medusajs/medusa";
export async function GET(req: MedusaRequest, res: MedusaResponse) {
res.json({
message: "Hello world!",
});
}
```
## Supported HTTP methods
The file based routing supports the following HTTP methods:
- GET
- POST
- PUT
- PATCH
- DELETE
- OPTIONS
- HEAD
You can define a handler for each of these methods by exporting a function with the name of the method in the paths `route.ts` file.
For example:
```ts
import type { MedusaRequest, MedusaResponse } from "@medusajs/medusa";
export async function GET(req: MedusaRequest, res: MedusaResponse) {
// Handle GET requests
}
export async function POST(req: MedusaRequest, res: MedusaResponse) {
// Handle POST requests
}
export async function PUT(req: MedusaRequest, res: MedusaResponse) {
// Handle PUT requests
}
```
## Parameters
To create an API route that accepts a path parameter, create a directory within the route's path whose name is of the format `[param]`.
For example, if you want to define a route that takes a `productId` parameter, you can do so by creating a file called `/api/products/[productId]/route.ts`:
```ts
import type {
MedusaRequest,
MedusaResponse,
} from "@medusajs/medusa"
export async function GET(req: MedusaRequest, res: MedusaResponse) {
const { productId } = req.params;
res.json({
message: `You're looking for product ${productId}`
})
}
```
To create an API route that accepts multiple path parameters, create within the file's path multiple directories whose names are of the format `[param]`.
For example, if you want to define a route that takes both a `productId` and a `variantId` parameter, you can do so by creating a file called `/api/products/[productId]/variants/[variantId]/route.ts`.
## Using the container
The Medusa container is available on `req.scope`. Use it to access modules' main services and other registered resources:
```ts
import type {
MedusaRequest,
MedusaResponse,
} from "@medusajs/medusa"
import { IProductModuleService } from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
export const GET = async (
req: MedusaRequest,
res: MedusaResponse
) => {
const productModuleService: IProductModuleService =
req.scope.resolve(Modules.PRODUCT)
const [, count] = await productModuleService.listAndCount()
res.json({
count,
})
}
```
## Middleware
You can apply middleware to your routes by creating a file called `/api/middlewares.ts`. This file must export a configuration object with what middleware you want to apply to which routes.
For example, if you want to apply a custom middleware function to the `/store/custom` route, you can do so by adding the following to your `/api/middlewares.ts` file:
```ts
import { defineMiddlewares } from "@medusajs/medusa"
import type {
MedusaRequest,
MedusaResponse,
MedusaNextFunction,
} from "@medusajs/medusa";
async function logger(
req: MedusaRequest,
res: MedusaResponse,
next: MedusaNextFunction
) {
console.log("Request received");
next();
}
export default defineMiddlewares({
routes: [
{
matcher: "/store/custom",
middlewares: [logger],
},
],
})
```
The `matcher` property can be either a string or a regular expression. The `middlewares` property accepts an array of middleware functions.
================================================
FILE: medusa/src/api/admin/custom/collections/[collectionId]/details/route.ts
================================================
import { Modules } from '@medusajs/framework/utils';
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import { z } from '@medusajs/framework/zod';
const collectionFieldsMetadataSchema = z.object({
image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
description: z.string().optional(),
collection_page_image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
collection_page_heading: z.string().optional(),
collection_page_content: z.string().optional(),
product_page_heading: z.string().optional(),
product_page_image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
product_page_wide_image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
product_page_cta_image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
product_page_cta_heading: z.string().optional(),
product_page_cta_link: z.string().optional(),
});
export async function GET(
req: MedusaRequest,
res: MedusaResponse,
): Promise<void> {
const { collectionId } = req.params;
const productService = req.scope.resolve(Modules.PRODUCT);
const collection =
await productService.retrieveProductCollection(collectionId);
const parsed = collectionFieldsMetadataSchema.safeParse(
collection.metadata ?? {},
);
res.json({
image: parsed.success && parsed.data.image ? parsed.data.image : null,
description:
parsed.success && parsed.data.description ? parsed.data.description : '',
collection_page_image:
parsed.success && parsed.data.collection_page_image
? parsed.data.collection_page_image
: null,
collection_page_heading:
parsed.success && parsed.data.collection_page_heading
? parsed.data.collection_page_heading
: '',
collection_page_content:
parsed.success && parsed.data.collection_page_content
? parsed.data.collection_page_content
: '',
product_page_heading:
parsed.success && parsed.data.product_page_heading
? parsed.data.product_page_heading
: '',
product_page_image:
parsed.success && parsed.data.product_page_image
? parsed.data.product_page_image
: null,
product_page_wide_image:
parsed.success && parsed.data.product_page_wide_image
? parsed.data.product_page_wide_image
: null,
product_page_cta_image:
parsed.success && parsed.data.product_page_cta_image
? parsed.data.product_page_cta_image
: null,
product_page_cta_heading:
parsed.success && parsed.data.product_page_cta_heading
? parsed.data.product_page_cta_heading
: '',
product_page_cta_link:
parsed.success && parsed.data.product_page_cta_link
? parsed.data.product_page_cta_link
: '',
});
}
export async function POST(
req: MedusaRequest<typeof collectionFieldsMetadataSchema>,
res: MedusaResponse,
): Promise<void> {
const { collectionId } = req.params;
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
const customFields = collectionFieldsMetadataSchema.parse(body);
const productService = req.scope.resolve(Modules.PRODUCT);
const collection =
await productService.retrieveProductCollection(collectionId);
const updatedCollection = await productService.updateProductCollections(
collectionId,
{
metadata: {
...collection.metadata,
...customFields,
},
},
);
res.json(updatedCollection);
}
================================================
FILE: medusa/src/api/admin/custom/index-products/route.ts
================================================
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from '@medusajs/framework';
import { indexProductsWorkflow } from '../../../../workflows/index-products';
export async function POST(
req: AuthenticatedMedusaRequest,
res: MedusaResponse,
): Promise<void> {
const result = await indexProductsWorkflow(req.scope).run();
res.json(result);
}
================================================
FILE: medusa/src/api/admin/custom/product-types/[productTypeId]/details/route.ts
================================================
import { Modules } from '@medusajs/framework/utils';
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import { z } from '@medusajs/framework/zod';
const productTypeFieldsMetadataSchema = z.object({
image: z
.object({
id: z.string(),
url: z.string().url(),
})
.optional(),
});
export async function GET(
req: MedusaRequest,
res: MedusaResponse,
): Promise<void> {
const { productTypeId } = req.params;
const productService = req.scope.resolve(Modules.PRODUCT);
const productType = await productService.retrieveProductType(productTypeId);
const parsed = productTypeFieldsMetadataSchema.safeParse(
productType.metadata ?? {},
);
res.json({
image: parsed.success && parsed.data.image ? parsed.data.image : null,
});
}
export async function POST(
req: MedusaRequest,
res: MedusaResponse,
): Promise<void> {
const { productTypeId } = req.params;
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
const customFields = productTypeFieldsMetadataSchema.parse(body);
const productService = req.scope.resolve(Modules.PRODUCT);
const productType = await productService.retrieveProductType(productTypeId);
const updatedProductType = await productService.updateProductTypes(
productTypeId,
{
metadata: {
...productType.metadata,
...customFields,
},
},
);
res.json(updatedProductType);
}
================================================
FILE: medusa/src/api/admin/fashion/[id]/colors/[colorId]/restore/route.ts
================================================
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import FashionModuleService from '../../../../../../../modules/fashion/service';
import { FASHION_MODULE } from '../../../../../../../modules/fashion';
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
await fashionModuleService.retrieveMaterial(req.params.id, {
withDeleted: true,
});
await fashionModuleService.restoreColors(req.params.colorId);
const color = await fashionModuleService.retrieveColor(req.params.colorId, {
withDeleted: true,
});
res.status(200).json(color);
};
================================================
FILE: medusa/src/api/admin/fashion/[id]/colors/[colorId]/route.ts
================================================
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import { z } from '@medusajs/framework/zod';
import FashionModuleService from '../../../../../../modules/fashion/service';
import { FASHION_MODULE } from '../../../../../../modules/fashion';
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
await fashionModuleService.retrieveMaterial(req.params.id, {
withDeleted: true,
});
const color = await fashionModuleService.retrieveColor(req.params.colorId, {
withDeleted: true,
});
res.status(200).json(color);
};
const colorsUpdateBodySchema = z.object({
name: z.string().min(1),
hex_code: z
.string()
.min(1)
.transform((val) => val.toUpperCase())
.refine((val) => /^#([A-F0-9]{6}|[A-F0-9]{3})$/.test(val), {
message: 'Invalid hex code',
}),
});
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
await fashionModuleService.retrieveMaterial(req.params.id, {
withDeleted: true,
});
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
const validatedData = colorsUpdateBodySchema.parse(body);
const color = await fashionModuleService.updateColors({
...validatedData,
id: req.params.colorId,
});
res.status(200).json(color);
};
export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
await fashionModuleService.retrieveMaterial(req.params.id, {
withDeleted: true,
});
await fashionModuleService.softDeleteColors(req.params.colorId);
const color = await fashionModuleService.retrieveColor(req.params.colorId, {
withDeleted: true,
});
res.status(200).json(color);
};
================================================
FILE: medusa/src/api/admin/fashion/[id]/colors/route.ts
================================================
import { z } from '@medusajs/framework/zod';
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import FashionModuleService from '../../../../../modules/fashion/service';
import { FASHION_MODULE } from '../../../../../modules/fashion';
const colorsListQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
deleted: z.coerce.boolean().optional().default(false),
});
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const { page, deleted } = colorsListQuerySchema.parse(req.query);
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const [colors, count] = await fashionModuleService.listAndCountColors(
deleted
? {
deleted_at: { $lte: new Date() },
material_id: req.params.id,
}
: {
material_id: req.params.id,
},
{
skip: 20 * (page - 1),
take: 20,
withDeleted: deleted,
},
);
const last_page = Math.ceil(count / 20);
res.status(200).json({ colors, count, page, last_page });
};
const colorsCreateBodySchema = z.object({
name: z.string().min(1),
hex_code: z.string().min(7).max(7),
});
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
const validatedData = colorsCreateBodySchema.parse(body);
const color = await fashionModuleService.createColors({
...validatedData,
material_id: req.params.id,
});
res.status(200).json(color);
};
================================================
FILE: medusa/src/api/admin/fashion/[id]/restore/route.ts
================================================
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import FashionModuleService from '../../../../../modules/fashion/service';
import { FASHION_MODULE } from '../../../../../modules/fashion';
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
await fashionModuleService.restoreMaterials(req.params.id);
const material = await fashionModuleService.retrieveMaterial(req.params.id, {
relations: ['colors'],
withDeleted: true,
});
res.status(200).json(material);
};
================================================
FILE: medusa/src/api/admin/fashion/[id]/route.ts
================================================
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import { z } from '@medusajs/framework/zod';
import FashionModuleService from '../../../../modules/fashion/service';
import { FASHION_MODULE } from '../../../../modules/fashion';
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const material = await fashionModuleService.retrieveMaterial(req.params.id, {
relations: ['colors'],
withDeleted: true,
});
res.status(200).json(material);
};
const updateMaterialBodySchema = z.object({
name: z.string().min(1),
});
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
const validatedData = updateMaterialBodySchema.parse(body);
const material = await fashionModuleService.updateMaterials({
...validatedData,
id: req.params.id,
});
res.status(200).json(material);
};
export const DELETE = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
await fashionModuleService.softDeleteMaterials(req.params.id);
const material = await fashionModuleService.retrieveMaterial(req.params.id, {
relations: ['colors'],
withDeleted: true,
});
res.status(200).json(material);
};
================================================
FILE: medusa/src/api/admin/fashion/route.ts
================================================
import { z } from '@medusajs/framework/zod';
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import FashionModuleService from '../../../modules/fashion/service';
import { FASHION_MODULE } from '../../../modules/fashion';
const materialsListQuerySchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
deleted: z.coerce.boolean().optional().default(false),
});
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const { page, deleted } = materialsListQuerySchema.parse(req.query);
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const [materials, count] = await fashionModuleService.listAndCountMaterials(
deleted
? {
deleted_at: { $lte: new Date() },
}
: undefined,
{
skip: 20 * (page - 1),
take: 20,
withDeleted: deleted,
relations: ['colors'],
},
);
const last_page = Math.ceil(count / 20);
res.status(200).json({ materials, count, page, last_page });
};
const createMaterialBodySchema = z.object({
name: z.string().min(1),
});
export const POST = async (req: MedusaRequest, res: MedusaResponse) => {
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const body = typeof req.body === 'string' ? JSON.parse(req.body) : req.body;
const validatedData = createMaterialBodySchema.parse(body);
const material = await fashionModuleService.createMaterials(validatedData);
res.status(201).json(material);
};
================================================
FILE: medusa/src/api/admin/products/[id]/fashion/route.ts
================================================
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import { Modules } from '@medusajs/framework/utils';
import { IProductModuleService } from '@medusajs/framework/types';
import { FASHION_MODULE } from '../../../../../modules/fashion';
import FashionModuleService from '../../../../../modules/fashion/service';
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const productModuleService: IProductModuleService = req.scope.resolve(
Modules.PRODUCT,
);
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const product = await productModuleService.retrieveProduct(req.params.id, {
relations: ['options', 'variants', 'variants.options'],
});
const materialOption = product.options.find(
(option) => option.title === 'Material',
);
const colorOption = product.options.find(
(option) => option.title === 'Color',
);
const materialsAndColorsNamesTree = new Map<string, string[]>();
for (const productVariant of product.variants) {
const materialName = productVariant.options.find(
(option) => option.option_id === materialOption.id,
)?.value;
if (!materialName) {
continue;
}
const colorNames = productVariant.options
.filter((option) => option.option_id === colorOption.id)
.map((option) => option.value);
if (!materialsAndColorsNamesTree.has(materialName)) {
materialsAndColorsNamesTree.set(materialName, colorNames);
} else {
const existingColorNames = materialsAndColorsNamesTree.get(materialName);
materialsAndColorsNamesTree.set(
materialName,
Array.from(new Set([...existingColorNames, ...colorNames])),
);
}
}
const materials = await fashionModuleService.listMaterials(
{
name: Array.from(materialsAndColorsNamesTree.keys()),
},
{
relations: ['colors'],
},
);
res.status(200).json({
missing_materials: Array.from(materialsAndColorsNamesTree.keys()).filter(
(materialName) =>
materials.every((material) => material.name !== materialName),
),
materials: materials.map((material) => ({
...material,
colors: material.colors.filter((color) =>
materialsAndColorsNamesTree.get(material.name).includes(color.name),
),
missing_colors: materialsAndColorsNamesTree
.get(material.name)
.filter((colorName) =>
material.colors.every((color) => color.name !== colorName),
),
})),
});
};
================================================
FILE: medusa/src/api/middlewares.ts
================================================
import { defineMiddlewares } from '@medusajs/medusa';
import { adminProductTypeRoutesMiddlewares } from './store/custom/product-types/middlewares';
import { authenticate } from '@medusajs/framework';
export default defineMiddlewares([
...adminProductTypeRoutesMiddlewares,
{
method: 'ALL',
matcher: '/store/custom/customer/*',
middlewares: [authenticate('customer', ['session', 'bearer'])],
},
]);
================================================
FILE: medusa/src/api/store/custom/customer/send-welcome-email/route.ts
================================================
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from '@medusajs/framework';
import emitCustomerWelcomeEvent from '../../../../../workflows/emit-customer-welcome-event';
export const POST = async (
req: AuthenticatedMedusaRequest,
res: MedusaResponse,
) => {
const customerId = req.auth_context.actor_id;
await emitCustomerWelcomeEvent(req.scope).run({
input: {
id: customerId,
},
});
res.status(200).json({ success: true });
};
================================================
FILE: medusa/src/api/store/custom/fashion/[productHandle]/route.ts
================================================
import { MedusaRequest, MedusaResponse } from '@medusajs/framework';
import { Modules } from '@medusajs/framework/utils';
import { IProductModuleService } from '@medusajs/framework/types';
import { FASHION_MODULE } from '../../../../../modules/fashion';
import FashionModuleService from '../../../../../modules/fashion/service';
export const GET = async (req: MedusaRequest, res: MedusaResponse) => {
const productModuleService: IProductModuleService = req.scope.resolve(
Modules.PRODUCT,
);
const fashionModuleService: FashionModuleService =
req.scope.resolve(FASHION_MODULE);
const [product] = await productModuleService.listProducts(
{
handle: req.params.productHandle,
},
{
relations: ['options', 'variants', 'variants.options'],
take: 1,
},
);
const materialOption = product.options.find(
(option) => option.title === 'Material',
);
const colorOption = product.options.find(
(option) => option.title === 'Color',
);
if (!materialOption || !colorOption) {
res.status(200).json({
materials: [],
});
return;
}
const materialsAndColorsNamesTree = new Map<string, string[]>();
for (const productVariant of product.variants) {
const materialName = productVariant.options.find(
(option) => option.option_id === materialOption.id,
)?.value;
if (!materialName) {
continue;
}
const colorNames = productVariant.options
.filter((option) => option.option_id === colorOption.id)
.map((option) => option.value);
if (!materialsAndColorsNamesTree.has(materialName)) {
materialsAndColorsNamesTree.set(materialName, colorNames);
} else {
const existingColorNames = materialsAndColorsNamesTree.get(materialName);
materialsAndColorsNamesTree.set(
materialName,
Array.from(new Set([...existingColorNames, ...colorNames])),
);
}
}
const materials = await fashionModuleService.listMaterials(
{
name: Array.from(materialsAndColorsNamesTree.keys()),
},
{
relations: ['colors'],
},
);
res.status(200).json({
materials: materials.map((material) => ({
id: material.id,
name: material.name,
colors: material.colors
.filter((color) =>
materialsAndColorsNamesTree.get(material.name).includes(color.name),
)
.map((color) => ({
id: color.id,
name: color.name,
hex_code: color.hex_code,
})),
})),
});
};
================================================
FILE: medusa/src/api/store/custom/product-types/[id]/route.ts
================================================
import { refetchProductType } from '../helpers';
import { AdminGetProductTypeParamsType } from '../validators';
import { ProductTypeDTO } from '@medusajs/framework/types';
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from '@medusajs/framework';
export const GET = async (
req: AuthenticatedMedusaRequest<AdminGetProductTypeParamsType>,
res: MedusaResponse
) => {
const productType = await refetchProductType(
req.params.id,
req.scope,
req.remoteQueryConfig.fields as (keyof ProductTypeDTO)[],
);
res.status(200).json({ product_type: productType });
};
================================================
FILE: medusa/src/api/store/custom/product-types/helpers.ts
================================================
import { MedusaContainer, ProductTypeDTO } from "@medusajs/framework/types"
export const refetchProductType = async (
productTypeId: string,
scope: MedusaContainer,
fields: (keyof ProductTypeDTO)[]
) => {
const query = scope.resolve("query")
const { data: [ productType ] } = await query.graph({
entity: "product_type",
filters: { id: productTypeId },
fields,
})
return productType
}
================================================
FILE: medusa/src/api/store/custom/product-types/middlewares.ts
================================================
import * as QueryConfig from './query-config';
import {
MiddlewareRoute,
validateAndTransformQuery,
} from '@medusajs/framework/http';
import {
AdminGetProductTypeParams,
AdminGetProductTypesParams,
} from './validators';
export const adminProductTypeRoutesMiddlewares: MiddlewareRoute[] = [
{
method: ['GET'],
matcher: '/store/custom/product-types',
middlewares: [
validateAndTransformQuery(
AdminGetProductTypesParams,
QueryConfig.listProductTypesTransformQueryConfig,
),
],
},
{
method: ['GET'],
matcher: '/store/custom/product-types/:id',
middlewares: [
validateAndTransformQuery(
AdminGetProductTypeParams,
QueryConfig.retrieveProductTypeTransformQueryConfig,
),
],
},
];
================================================
FILE: medusa/src/api/store/custom/product-types/query-config.ts
================================================
export const defaultAdminProductTypeFields = [
"id",
"value",
"created_at",
"updated_at",
]
export const retrieveProductTypeTransformQueryConfig = {
defaults: defaultAdminProductTypeFields,
isList: false,
}
export const listProductTypesTransformQueryConfig = {
...retrieveProductTypeTransformQueryConfig,
defaultLimit: 20,
isList: true,
}
================================================
FILE: medusa/src/api/store/custom/product-types/route.ts
================================================
import { HttpTypes, ProductTypeDTO } from '@medusajs/framework/types';
import {
AuthenticatedMedusaRequest,
MedusaResponse,
} from '@medusajs/framework';
export const GET = async (
req: AuthenticatedMedusaRequest<HttpTypes.AdminProductTypeListParams>,
res: MedusaResponse,
) => {
const query = req.scope.resolve("query")
const { data: productTypes, metadata } = await query.graph({
entity: "product_types",
filters: req.filterableFields,
fields: req.remoteQueryConfig.fields as (keyof ProductTypeDTO)[],
pagination: req.remoteQueryConfig.pagination
})
res.json({
product_types: productTypes,
count: metadata.count,
offset: metadata.skip,
limit: metadata.take,
});
};
================================================
FILE: medusa/src/api/store/custom/product-types/validators.ts
================================================
import {
createSelectParams,
createFindParams,
createOperatorMap,
} from '@medusajs/medusa/api/utils/validators';
import { z } from '@medusajs/framework/zod';
export type AdminGetProductTypeParamsType = z.infer<
typeof AdminGetProductTypeParams
>;
export const AdminGetProductTypeParams = createSelectParams();
export type AdminGetProductTypesParamsType = z.infer<
typeof AdminGetProductTypesParams
>;
export const AdminGetProductTypesParams = createFindParams({
limit: 10,
offset: 0,
}).merge(
z.object({
q: z.string().optional(),
id: z.union([z.string(), z.array(z.string())]).optional(),
value: z.union([z.string(), z.array(z.string())]).optional(),
// TODO: To be added in next iteration
// discount_condition_id: z.string().nullish(),
created_at: createOperatorMap().optional(),
updated_at: createOperatorMap().optional(),
deleted_at: createOperatorMap().optional(),
$and: z.lazy(() => AdminGetProductTypesParams.array()).optional(),
$or: z.lazy(() => AdminGetProductTypesParams.array()).optional(),
}),
);
================================================
FILE: medusa/src/api/store/custom/stripe/get-payment-method/[id]/route.ts
================================================
import { MedusaResponse, MedusaStoreRequest } from "@medusajs/framework";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_API_KEY);
export const GET = async (req: MedusaStoreRequest, res: MedusaResponse) => {
const { id } = req.params;
const paymentMethod = await stripe.paymentMethods.retrieve(id);
res.status(200).json(paymentMethod);
};
================================================
FILE: medusa/src/api/store/custom/stripe/set-payment-method/route.ts
================================================
import { MedusaResponse, MedusaStoreRequest } from "@medusajs/framework";
import { IPaymentModuleService } from "@medusajs/framework/types";
import { Modules } from "@medusajs/framework/utils";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_API_KEY);
export const POST = async (
req: MedusaStoreRequest<{
session_id: string;
token: string;
}>,
res: MedusaResponse
) => {
const paymentModuleService: IPaymentModuleService = req.scope.resolve(
Modules.PAYMENT
);
const session = await paymentModuleService.retrievePaymentSession(
req.body.session_id
);
if (!req.body.token) {
await paymentModuleService.updatePaymentSession({
...session,
data: {
...session.data,
payment_method_id: null,
},
});
res.status(200).json({ success: true });
}
const paymentMethod = await stripe.paymentMethods.create({
type: "card",
card: { token: req.body.token },
});
await stripe.paymentIntents.update(session.data.id as string, {
payment_method: paymentMethod.id,
});
await paymentModuleService.updatePaymentSession({
...session,
data: {
...session.data,
payment_method_id: paymentMethod.id,
},
});
res.status(200).json({ success: true });
};
================================================
FILE: medusa/src/jobs/README.md
================================================
# Custom scheduled jobs
A scheduled job is a function executed at a specified interval of time in the background of your Medusa application.
A scheduled job is created in a TypeScript or JavaScript file under the `src/jobs` directory.
For example, create the file `src/jobs/hello-world.ts` with the following content:
```ts
import {
IProductModuleService,
MedusaContainer
} from "@medusajs/framework/types";
import { Modules } from "@medusajs/framework/utils";
export default async function myCustomJob(container: MedusaContainer) {
const productService: IProductModuleService = container.resolve(Modules.PRODUCT)
const products = await productService.listAndCountProducts();
// Do something with the products
}
export const config = {
name: "daily-product-report",
schedule: "0 0 * * *", // Every day at midnight
};
```
A scheduled job file must export:
- The function to be executed whenever it’s time to run the scheduled job.
- A configuration object defining the job. It has three properties:
- `name`: a unique name for the job.
- `schedule`: a [cron expression](https://crontab.guru/).
- `numberOfExecutions`: an optional integer, specifying how many times the job will execute before being removed
The `handler` is a function that accepts one parameter, `container`, which is a `MedusaContainer` instance used to resolve services.
================================================
FILE: medusa/src/links/README.md
================================================
# Module Links
A module link forms an association between two data models of different modules, while maintaining module isolation.
For example:
```ts
import HelloModule from "../modules/hello"
import ProductModule from "@medusajs/medusa/product"
import { defineLink } from "@medusajs/framework/utils"
export default defineLink(
ProductModule.linkable.product,
HelloModule.linkable.myCustom
)
```
This defines a link between the Product Module's `product` data model and the Hello Module (custom module)'s `myCustom` data model.
Learn more about links in [this documentation](https://docs.medusajs.com/v2/advanced-development/modules/module-links)
================================================
FILE: medusa/src/modules/README.md
================================================
# Custom Module
A module is a package of reusable functionalities. It can be integrated into your Medusa application without affecting the overall system.
To create a module:
## 1. Create a Service
A module must define a service. A service is a TypeScript or JavaScript class holding methods related to a business logic or commerce functionality.
For example, create the file `src/modules/hello/service.ts` with the following content:
```ts title="src/modules/hello/service.ts"
export default class HelloModuleService {
getMessage() {
return "Hello, world!"
}
}
```
## 2. Export Module Definition
A module must have an `index.ts` file in its root directory that exports its definition. The definition specifies the main service of the module.
For example, create the file `src/modules/hello.index.ts` with the following content:
```ts title="src/modules/hello.index.ts" highlights={[["4", "", "The main service of the module."]]}
import HelloModuleService from "./service"
import { Module } from "@medusajs/framework/utils"
export const HELLO_MODULE = "helloModuleService"
export default Module(HELLO_MODULE, {
service: HelloModuleService,
})
```
## 3. Add Module to Configurations
The last step is to add the module in Medusa’s configurations.
In `medusa-config.js`, add the module to the `modules` object:
```js title="medusa-config.js"
import { HELLO_MODULE } from "./src/modules/hello"
module.exports = defineConfig({
// ...
modules: {
[HELLO_MODULE]: {
resolve: "./modules/hello",
},
},
})
```
Its key (`helloModuleService` or `HELLO_MODULE`) is the name of the module’s main service. It will be registered in the Medusa container with that name.
## Use Module
You can resolve the main service of the module in other resources, such as an API route:
```ts
import { MedusaRequest, MedusaResponse } from "@medusajs/medusa"
import HelloModuleService from "../../../modules/hello/service"
import { HELLO_MODULE } from "../../../modules/hello"
export async function GET(
req: MedusaRequest,
res: MedusaResponse
): Promise<void> {
const helloModuleService: HelloModuleService = req.scope.resolve(
HELLO_MODULE
)
res.json({
message: helloModuleService.getMessage(),
})
}
```
================================================
FILE: medusa/src/modules/fashion/index.ts
================================================
import { Module } from '@medusajs/framework/utils';
import FashionModuleService from './service';
export const FASHION_MODULE = 'fashionModuleService';
export default Module(FASHION_MODULE, {
service: FashionModuleService,
});
================================================
FILE: medusa/src/modules/fashion/migrations/.snapshot-medusa.json
================================================
{
"namespaces": [
"public"
],
"name": "public",
"tables": [
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"name": {
"name": "name",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "material",
"schema": "public",
"indexes": [
{
"keyName": "material_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {}
},
{
"columns": {
"id": {
"name": "id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"name": {
"name": "name",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"hex_code": {
"name": "hex_code",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"material_id": {
"name": "material_id",
"type": "text",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"mappedType": "text"
},
"created_at": {
"name": "created_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"updated_at": {
"name": "updated_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": false,
"length": 6,
"default": "now()",
"mappedType": "datetime"
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamptz",
"unsigned": false,
"autoincrement": false,
"primary": false,
"nullable": true,
"length": 6,
"mappedType": "datetime"
}
},
"name": "color",
"schema": "public",
"indexes": [
{
"keyName": "IDX_color_material_id",
"columnNames": [],
"composite": false,
"primary": false,
"unique": false,
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_color_material_id\" ON \"color\" (material_id) WHERE deleted_at IS NULL"
},
{
"keyName": "color_pkey",
"columnNames": [
"id"
],
"composite": false,
"primary": true,
"unique": true
}
],
"checks": [],
"foreignKeys": {
"color_material_id_foreign": {
"constraintName": "color_material_id_foreign",
"columnNames": [
"material_id"
],
"localTableName": "public.color",
"referencedColumnNames": [
"id"
],
"referencedTableName": "public.material",
"updateRule": "cascade"
}
}
}
]
}
================================================
FILE: medusa/src/modules/fashion/migrations/Migration20241002190028.ts
================================================
import { Migration } from '@mikro-orm/migrations';
export class Migration20241002190028 extends Migration {
async up(): Promise<void> {
this.addSql('create table if not exists "material" ("id" text not null, "name" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "material_pkey" primary key ("id"));');
this.addSql('create table if not exists "color" ("id" text not null, "name" text not null, "hex_code" text not null, "material_id" text not null, "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "color_pkey" primary key ("id"));');
this.addSql('CREATE INDEX IF NOT EXISTS "IDX_color_material_id" ON "color" (material_id) WHERE deleted_at IS NULL;');
this.addSql('alter table if exists "color" add constraint "color_material_id_foreign" foreign key ("material_id") references "material" ("id") on update cascade;');
}
async down(): Promise<void> {
this.addSql('alter table if exists "color" drop constraint if exists "color_material_id_foreign";');
this.addSql('drop table if exists "material" cascade;');
this.addSql('drop table if exists "color" cascade;');
}
}
================================================
FILE: medusa/src/modules/fashion/models/color.ts
================================================
import { model } from '@medusajs/framework/utils';
import { InferTypeOf } from '@medusajs/framework/types';
import Material from './material';
const Color = model.define('color', {
id: model.id().primaryKey(),
name: model.text(),
hex_code: model.text(),
material: model.belongsTo(() => Material, {
mappedBy: 'colors',
}),
});
export type ColorModelType = InferTypeOf<typeof Color>;
export default Color;
================================================
FILE: medusa/src/modules/fashion/models/material.ts
================================================
import { model } from '@medusajs/framework/utils';
import { InferTypeOf } from '@medusajs/framework/types';
import Color from './color';
const Material = model.define('material', {
id: model.id().primaryKey(),
name: model.text(),
colors: model.hasMany(() => Color),
});
export type MaterialModelType = InferTypeOf<typeof Material>;
export default Material;
================================================
FILE: medusa/src/modules/fashion/service.ts
================================================
import { MedusaService } from '@medusajs/framework/utils';
import Material from './models/material';
import Color from './models/color';
export default class FashionModuleService extends MedusaService({
Material,
Color,
}) {}
================================================
FILE: medusa/src/modules/meilisearch/index.ts
================================================
import { Module } from '@medusajs/utils';
import Loader from './loader';
import { MeiliSearchService } from './service';
export default Module('meilisearchService', {
service: MeiliSearchService,
loaders: [Loader],
});
================================================
FILE: medusa/src/modules/meilisearch/loader.ts
================================================
import { LoaderOptions } from '@medusajs/types';
import { MeiliSearchService } from './service';
import { MeiliSearchPluginOptions } from './types';
import { asValue } from 'awilix';
export default async ({
container,
options,
}: LoaderOptions<MeiliSearchPluginOptions>): Promise<void> => {
if (!options) {
throw new Error('Missing meilisearch configuration');
}
const meilisearchService: MeiliSearchService = new MeiliSearchService(
container,
options,
);
container.register({
meilisearchService: asValue(meilisearchService),
});
if (options.settings) {
await Promise.all(
Object.entries(options.settings).map(([indexName, indexSettings]) =>
meilisearchService.updateSettings(indexName, indexSettings),
),
);
}
};
================================================
FILE: medusa/src/modules/meilisearch/service.ts
================================================
import { SearchTypes } from '@medusajs/types';
import { SearchUtils } from '@medusajs/utils';
// @ts-ignore
import { MeiliSearch, MeiliSearchApiError, Settings } from 'meilisearch';
import { MeiliSearchPluginOptions } from './types';
import { logger } from '@medusajs/framework';
export class MeiliSearchService extends SearchUtils.AbstractSearchService {
static identifier = 'meilisearch';
isDefault = false;
protected readonly client: MeiliSearch;
constructor(container: any, options: MeiliSearchPluginOptions) {
super(container, options);
if (process.env.NODE_ENV !== 'development') {
if (!options.config?.apiKey) {
throw Error(
'MeiliSearch API key is required for production environments.',
);
}
}
if (!options.config?.host) {
throw Error(
'MeiliSearch host is required. Please provide a host in the configuration.',
);
}
this.client = new MeiliSearch(options.config);
}
async createIndex(
indexName: string,
options: Record<string, unknown> = { primaryKey: 'id' },
) {
return this.client.createIndex(indexName, options);
}
getIndex(indexName: string) {
return this.client.index(indexName);
}
async addDocuments(
indexName: string,
documents: Record<string, any>[],
type: string,
) {
const indexSetting = this.options.settings?.[indexName];
const transformer = indexSetting?.transformer ?? ((doc: any) => doc);
const primaryKey = indexSetting?.primaryKey ?? 'id';
return this.client
.index(indexName)
.addDocuments(documents.map(transformer), { primaryKey });
}
async replaceDocuments(
indexName: string,
documents: Record<string, any>[],
type: string,
) {
return this.addDocuments(indexName, documents, type);
}
async deleteDocument(indexName: string, documentId: string) {
return this.client.index(indexName).deleteDocument(documentId);
}
async deleteAllDocuments(indexName: string) {
return this.client.index(indexName).deleteAllDocuments();
}
async search(indexName: string, query: string, options: Record<string, any>) {
const { paginationOptions, filter, additionalOptions } = options;
return this.client
.index(indexName)
.search(query, { filter, ...paginationOptions, ...additionalOptions });
}
async updateSettings(
indexName: string,
settings: SearchTypes.IndexSettings & { indexSettings: Settings },
) {
const indexSettings = settings.indexSettings ?? {};
try {
await this.client.getIndex(indexName);
} catch (error) {
if (
error instanceof MeiliSearchApiError &&
error.cause?.code === 'index_not_found'
) {
await this.createIndex(indexName, {
primaryKey: settings.primaryKey ?? 'id',
});
} else {
logger.error(error);
throw error;
}
}
return this.client.index(indexName).updateSettings(indexSettings);
}
}
================================================
FILE: medusa/src/modules/meilisearch/types.ts
================================================
import { SearchTypes } from '@medusajs/types';
// @ts-ignore
import type { Config, Settings } from 'meilisearch';
export interface MeiliSearchPluginOptions {
/**
* MeiliSearch client configuration
*/
config: Config;
/**
* MeiliSearch index settings
*/
settings?: Record<
string,
SearchTypes.IndexSettings & { indexSettings: Settings }
>;
}
================================================
FILE: medusa/src/modules/resend/emails/auth-email-confirm.tsx
================================================
// External packages
import { Text, Heading, Button } from '@react-email/components';
// Components
import EmailLayout, { EmailLayoutProps } from './components/EmailLayout';
export default function AuthEmailConfirm({
...emailLayoutProps
}: EmailLayoutProps) {
return (
<EmailLayout {...emailLayoutProps}>
<Heading className="text-2xl mt-0 mb-11 font-medium">
Verify your email
</Heading>
<Text className="text-md !mb-10">
Hey Jovana, thanks for registering for an account on Sofa Society!
</Text>
<Text className="text-md !mb-10">
Before we get started, we just need to confirm that this is you.
<br />
Click below to verify your email address:
</Text>
<Button className="inline-flex items-center rounded-xs justify-center transition-colors bg-black text-white h-10 px-6">
Verify email
</Button>
</EmailLayout>
);
}
================================================
FILE: medusa/src/modules/resend/emails/auth-forgot-password.tsx
================================================
// External components
import { Text, Heading, Button } from '@react-email/components';
// Types
import { CustomerDTO } from '@medusajs/framework/types';
// Components
import EmailLayout, { EmailLayoutProps } from './components/EmailLayout';
type Props = {
customer: Pick<CustomerDTO, 'id' | 'email' | 'first_name' | 'last_name'>;
token: string;
};
export default function AuthPasswordForgotResetEmail({
customer,
token,
...emailLayoutProps
}: Props & EmailLayoutProps) {
return (
<EmailLayout {...emailLayoutProps}>
<Heading className="text-2xl mt-0 mb-10 font-medium">
Reset your password
</Heading>
<Text className="text-md !mb-10">
We received a request to reset your Sofa Society account password. Click
below to set a new password:
</Text>
<Button
href={`${
process.env.STOREFRONT_URL || 'http://localhost:8000'
}/auth/forgot-password/reset?email=${encodeURIComponent(
customer.email,
)}&token=${encodeURIComponent(token)}`}
className="inline-flex items-center rounded-xs justify-center transition-colors bg-black text-white h-10 px-6 mb-10"
>
Reset password
</Button>
<Text className="text-md text-grayscale-500 m-0">
If you didn't request this change, please ignore this email, and
your current password will remain unchanged.
</Text>
</EmailLayout>
);
}
AuthPasswordForgotResetEmail.PreviewProps = {
customer: {
id: '1',
email: 'example@medusa.local',
first_name: 'John',
last_name: 'Doe',
},
token: '1234567789012345677890',
} satisfies Props;
================================================
FILE: medusa/src/modules/resend/emails/auth-password-reset.tsx
================================================
// External components
import { Text, Heading, Button } from '@react-email/components';
// Types
import { CustomerDTO } from '@medusajs/framework/types';
// Components
import EmailLayout, { EmailLayoutProps } from './components/EmailLayout';
type Props = {
customer: Pick<CustomerDTO, 'id' | 'email' | 'first_name' | 'last_name'>;
token: string;
};
export default function AuthPasswordResetEmail({
customer,
token,
...emailLayoutProps
}: Props & EmailLayoutProps) {
return (
<EmailLayout {...emailLayoutProps}>
<Heading className="text-2xl mt-0 mb-10 font-medium">
Reset your password
</Heading>
<Text className="text-md !mb-10">
We received a request to reset your Sofa Society account password. Click
below to set a new password:
</Text>
<Button
href={`${
process.env.STOREFRONT_URL || 'http://localhost:8000'
}/auth/reset-password?email=${encodeURIComponent(
customer.email,
)}&token=${encodeURIComponent(token)}`}
className="inline-flex items-center rounded-xs justify-center bg-black text-white h-10 px-6 mb-10"
>
Reset password
</Button>
<Text className="text-md text-grayscale-500 m-0">
If you didn't request this change, please ignore this email, and
your current password will remain unchanged.
</Text>
</EmailLayout>
);
}
AuthPasswordResetEmail.PreviewProps = {
customer: {
id: '1',
email: 'example@medusa.local',
first_name: 'John',
last_name: 'Doe',
},
token: '1234567789012345677890',
} satisfies Props;
================================================
FILE: medusa/src/modules/resend/emails/components/EmailLayout.tsx
================================================
// External packages
import {
Body,
Column,
Container,
Font,
Head,
Hr,
Html,
Link,
Row,
Section,
Text,
Tailwind,
} from '@react-email/components';
// Google Font API is used to load the Mona Sans font
// You can find other variants here: https://webfonts.googleapis.com/v1/webfonts?capability=WOFF2&family=Mona%20Sans&subset=latin-ext&key=[YOUR_API_KEY]
export type EmailLayoutProps = {
siteTitle?: string;
companyName?: string;
footerLinks?: {
url: string;
label: string;
}[];
};
export default function EmailLayout(
props: {
children: React.ReactNode;
} & EmailLayoutProps
) {
return (
<Html>
<Head>
<Font
fontFamily="Mona Sans"
fallbackFontFamily={['Arial', 'Helvetica', 'Verdana', 'sans-serif']}
webFont={{
url: 'https://fonts.gstatic.com/s/monasans/v1/o-0mIpQmx24alC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyD9A99Y41P6zHtY.woff2',
format: 'woff2',
}}
fontWeight={400}
fontStyle="normal"
/>
<Font
fontFamily="Mona Sans"
fallbackFontFamily={['Arial', 'Helvetica', 'Verdana', 'sans-serif']}
webFont={{
url: 'https://fonts.gstatic.com/s/monasans/v1/o-0kIpQmx24alC5A4PNr4C5OaxRsfNNlKbCePevHtVtX57DGjDU1QDce6VLYyWtY1rI.woff2',
format: 'woff2',
}}
fontWeight={400}
fontStyle="italic"
/>
<Font
fontFamily="Mona Sans"
fallbackFontFamily={['Arial', 'Helvetica', 'Verdana', 'sans-serif']}
webFont={{
url: 'https://fonts.gstatic.com/s/monasans/v1/o-0mIpQmx24alC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyAjBN9Y41P6zHtY.woff2',
format: 'woff2',
}}
fontWeight={600}
fontStyle="normal"
/>
<Font
fontFamily="Mona Sans"
fallbackFontFamily={['Arial', 'Helvetica', 'Verdana', 'sans-serif']}
webFont={{
url: 'https://fonts.gstatic.com/s/monasans/v1/o-0kIpQmx24alC5A4PNr4C5OaxRsfNNlKbCePevHtVtX57DGjDU1QOkZ6VLYyWtY1rI.woff2',
format: 'woff2',
}}
fontWeight={600}
fontStyle="italic"
/>
<Font
fontFamily="Mona Sans"
fallbackFontFamily={['Arial', 'Helvetica', 'Verdana', 'sans-serif']}
webFont={{
url: 'https://fonts.gstatic.com/s/monasans/v1/o-0mIpQmx24alC5A4PNB6Ryti20_6n1iPHjcz6L1SoM-jCpoiyAaBN9Y41P6zHtY.woff2',
format: 'woff2',
}}
fontWeight={700}
fontStyle="normal"
/>
<Font
fontFamily="Mona Sans"
fallbackFontFamily={['Arial', 'Helvetica', 'Verdana', 'sans-serif']}
webFont={{
url: 'https://fonts.gstatic.com/s/monasans/v1/o-0kIpQmx24alC5A4PNr4C5OaxRsfNNlKbCePevHtVtX57DGjDU1QNAZ6VLYyWtY1rI.woff2',
format: 'woff2',
}}
fontWeight={700}
fontStyle="italic"
/>
</Head>
<Tailwind
config={{
theme: {
fontFamily: {
sans: 'Mona Sans',
},
extend: {
spacing: {
18: '4.5rem',
22: '5.5rem',
},
colors: {
grayscale: {
500: '#808080',
200: '#D1D1D1',
100: '#E7E7E7',
50: '#F4F4F4',
},
},
borderRadius: {
xs: '4px',
sm: '16px',
},
maxWidth: {
37: '9.25rem',
228: '57rem',
},
fontSize: {
'3xl': ['3.5rem', '1.5'],
'2xl': ['3rem', '1.5'],
xl: ['2.5rem', '1.5'],
lg: ['1.75rem', '1.5'],
md: ['1.5rem', '1.5'],
sm: ['1.125rem', '1.5'],
base: ['1rem', '1.5'],
xs: ['0.75rem', '1.5'],
},
},
},
}}
>
<Body className="bg-grayscale-50 font-normal">
<Container className="bg-white py-18 px-22 rounded-sm max-w-228 w-full">
<Link
href={process.env.STOREFRONT_URL || 'http://localhost:8000'}
className="text-lg mb-18 inline-block text-black"
>
{props.siteTitle || 'SofaSocietyCo.'}
</Link>
{props.children}
<Hr className="mt-20 mb-8" />
<Section className="gap-4 text-grayscale-500">
<Row>
<Column className="w-full">
<Link
href={process.env.STOREFRONT_URL || 'http://localhost:8000'}
className="text-lg text-grayscale-500"
>
{props.siteTitle || 'SofaSocietyCo.'}
</Link>
<Text className="text-xs m-0">
© {new Date().getFullYear()},{' '}
{props.companyName || 'Sofa Society'}
</Text>
</Column>
{props.footerLinks && props.footerLinks.length > 0 && (
<Column valign="top">
<Row>
{props.footerLinks.map((link, index) => (
<Column className="px-2" key={index}>
<Link href={link.url} className="text-grayscale-500">
{link.label}
</Link>
</Column>
))}
</Row>
</Column>
)}
</Row>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}
================================================
FILE: medusa/src/modules/resend/emails/index.ts
================================================
import AuthPasswordForgotResetEmail from "./auth-forgot-password";
import AuthPasswordResetEmail from "./auth-password-reset";
import OrderPlacedEmail from "./order-placed";
import WelcomeEmail from "./welcome";
// TODO: we should be able to use notification data in subjects too
export const subjects = {
"auth-password-reset": "Reset your password",
"order-placed": "Your order has been placed",
"customer-welcome": "Welcome to Sofa Society!",
"auth-forgot-password": "Reset your password",
};
export default {
"auth-password-reset": AuthPasswordResetEmail,
"order-placed": OrderPlacedEmail,
"customer-welcome": WelcomeEmail,
"auth-forgot-password": AuthPasswordForgotResetEmail,
};
================================================
FILE: medusa/src/modules/resend/emails/order-placed.tsx
================================================
// External packages
import { Fragment } from 'react';
import {
Text,
Column,
Heading,
Img,
Row,
Section,
Link,
Hr,
} from '@react-email/components';
import { HttpTypes } from '@medusajs/framework/types';
import EmailLayout, { EmailLayoutProps } from './components/EmailLayout';
export type OrderPlacedEmailProps = {
order: Pick<
HttpTypes.AdminOrder,
| 'currency_code'
| 'email'
| 'shipping_total'
| 'subtotal'
| 'total'
| 'tax_total'
> & {
shipping_address:
| (Pick<
HttpTypes.AdminOrderAddress,
| 'first_name'
| 'last_name'
| 'address_1'
| 'address_2'
| 'city'
| 'postal_code'
| 'province'
| 'phone'
> & {
country?: Pick<
HttpTypes.AdminRegionCountry,
'iso_2' | 'name' | 'display_name'
>;
})
| null;
billing_address:
| (Pick<
HttpTypes.AdminOrderAddress,
| 'first_name'
| 'last_name'
| 'address_1'
| 'address_2'
| 'city'
| 'postal_code'
| 'province'
| 'phone'
> & {
country?: Pick<
HttpTypes.AdminRegionCountry,
'iso_2' | 'name' | 'display_name'
>;
})
| null;
items: Pick<
HttpTypes.AdminOrder['items'][number],
| 'id'
| 'thumbnail'
| 'product_title'
| 'variant_title'
| 'total'
| 'quantity'
| 'variant_option_values'
>[];
};
} & EmailLayoutProps;
export default function OrderPlacedEmail({
order,
...emailLayoutProps
}: OrderPlacedEmailProps) {
const formatter = new Intl.NumberFormat([], {
style: 'currency',
currencyDisplay: 'narrowSymbol',
currency: order.currency_code,
});
return (
<EmailLayout {...emailLayoutProps}>
<Heading className="text-2xl font-medium mt-0 mb-10">
Order confirmation
</Heading>
<Text className="text-md !mb-6">
We are pleased to confirm that your order has been successfully placed
and will be processed shortly. Your order number is #100002.
</Text>
<Text className="text-md !mb-6">
You'll receive another update once your order is shipped. For any
questions, feel free to contact us at info@sofasociety.com.
</Text>
<Text className="text-md !mb-20">Thank you for shopping with us!</Text>
<Section className="mb-6">
<Row>
<Column className="border border-solid p-4 border-grayscale-200 rounded-xs">
<Text className="text-grayscale-500 !mt-0 !mb-8">
Delivery Address
</Text>
<Text className="m-0 leading-tight">
{[
order.shipping_address.first_name,
order.shipping_address.last_name,
]
.filter(Boolean)
.join(' ')}
</Text>
<Text className="m-0 leading-tight">
{[
order.shipping_address.address_1,
order.shipping_address.address_2,
[
order.shipping_address.postal_code,
order.shipping_address.city,
]
.filter(Boolean)
.join(' '),
order.shipping_address.province,
order.shipping_address.country.display_name,
]
.filter(Boolean)
.join(', ')}
</Text>
{order.shipping_address.phone && (
<Text className="m-0 leading-tight">
{order.shipping_address.phone}
</Text>
)}
</Column>
<Column className="w-8" />
<Column className="border border-solid p-4 border-grayscale-200 rounded-xs">
<Text className="text-grayscale-500 !mt-0 !mb-8">
Billing Address
</Text>
<Text className="m-0 leading-tight">
{[
order.billing_address.first_name,
order.billing_address.last_name,
]
.filter(Boolean)
.join(' ')}
</Text>
<Text className="m-0 leading-tight">
{[
order.billing_address.address_1,
order.billing_address.address_2,
[order.billing_address.postal_code, order.billing_address.city]
.filter(Boolean)
.join(' '),
order.billing_address.province,
order.billing_address.country.display_name,
]
.filter(Boolean)
.join(', ')}
</Text>
{order.billing_address.phone && (
<Text className="m-0 leading-tight">
{order.billing_address.phone}
</Text>
)}
</Column>
</Row>
</Section>
<Section className="border border-solid border-grayscale-200 rounded-xs px-4 mb-6">
{order.items.map((item, index) => {
return (
<Fragment key={item.id}>
{index > 0 && (
<Hr className="border-t border-solid border-grayscale-100 m-0" />
)}
<Row className="py-4">
<Column>
{!!item.thumbnail && (
<Link href="/">
<Img
src={item.thumbnail}
alt={item.product_title}
className="aspect-[3/4] object-cover max-w-37 float-left"
/>
</Link>
)}
</Column>
<Column className="w-full pl-8 relative" valign="top">
<Text className="text-md !mt-0 !mb-2">
{item.product_title}
</Text>
<Section className="mb-1">
{Object.entries(item.variant_option_values).flatMap(
([key, value]) =>
typeof value === 'string' ? (
<Row key={key}>
<Column className="flex">
<Text className="text-grayscale-500 m-0 text-xs">
{key}:
</Text>
<Text className="m-0 text-xs ml-2">{value}</Text>
</Column>
</Row>
) : (
[]
),
)}
<Row className="absolute bottom-0">
<Column className="flex">
<Text className="text-grayscale-500 m-0 text-xs">
Quantity:
</Text>
<Text className="m-0 text-xs ml-2">
{item.quantity}
</Text>
</Column>
</Row>
</Section>
</Column>
<Column valign="bottom">
<Text className="m-0 text-md">
{formatter.format(item.total)}
</Text>
</Column>
</Row>
</Fragment>
);
})}
</Section>
<Section className="border border-solid border-grayscale-200 rounded-xs p-4">
<Row>
<Column className="w-1/2 flex items-center" valign="top">
<Img
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAEySURBVHgB7ZbLccIwEIbXz/GMD3EJSgekgigdkAqSzkIHSSrAVAAdWB3A0eMn/4IBY+DGYg76ZtZr2Rrvb2klLZHFMjLO8EEURbqua0UCeJ5n8jxP6ZoABFYI/IvbCcliIOQDQsyZgCAIMjgF2ziO80f3J4FN2rZV8KuyLN+Ob3zf1xDQhmGYJYDkSBBnzbHiON6NtMsX/LHqOqQbQHLw6P7zTVEUJwFjMroAv99AgmjkwQ8JghjvdEsAUOjwTQ9kKGABm5EsXzB9VQAyNEN2zkgQTLHGKB/bdhVYAVbAGAJeaCgA69J0fkr7c1sEPuoRY3cKcnXEvl+QLGlfDRlsSCkJ0DTNFN9OYAYb3uuZAC7J0GHeVSxiIPjKdd3Pi5LsAFdHvQLlrvBUV1WVksXyTGwBvHxnj9a95poAAAAASUVORK5CYII="
alt="Credit card"
width="16"
height="16"
/>
<Text className="m-0 ml-2">Payment</Text>
</Column>
<Column className="w-1/2">
<Section>
<Row className="mb-2">
<Column className="flex">
<Text className="text-grayscale-500 m-0 text-base">
Subtotal
</Text>
<Text className="m-0 text-base ml-auto">
{formatter.format(order.subtotal)}
</Text>
</Column>
</Row>
<Row className="mb-6">
<Column className="flex">
<Text className="text-grayscale-500 m-0 text-base">
Shipping
</Text>
<Text className="m-0 text-base ml-auto">
{formatter.format(order.shipping_total)}
</Text>
</Column>
</Row>
<Row>
<Column className="flex">
<Text className="m-0 text-md">Total</Text>
<Text className="m-0 text-md ml-auto">
{formatter.format(order.total)}
</Text>
</Column>
</Row>
<Row>
<Column className="flex">
<Text className="text-grayscale-500 m-0 text-xs">
Including
</Text>
<Text className="m-0 text-xs text-grayscale-500 ml-1">
{formatter.format(order.tax_total)} tax
</Text>
</Column>
</Row>
</Section>
</Column>
</Row>
</Section>
</EmailLayout>
);
}
OrderPlacedEmail.PreviewProps = {
order: {
currency_code: 'EUR',
email: 'example@medusa.local',
shipping_address: {
first_name: 'John',
last_name: 'Doe',
address_1: '1234 Main St',
address_2: 'Apt 1',
city: 'Los Angeles',
postal_code: '90001',
country: {
iso_2: 'US',
name: 'United States',
display_name: 'United States',
},
phone: '+1 123 456 7890',
province: 'California',
},
billing_address: {
first_name: 'John',
last_name: 'Doe',
address_1: '1234 Main St',
address_2: 'Apt 1',
city: 'Los Angeles',
postal_code: '90001',
country: {
iso_2: 'US',
name: 'United States',
display_name: 'United States',
},
phone: '+1 123 456 7890',
province: 'California',
},
items: [
{
id: '1',
thumbnail:
'https://fashion-starter-demo.s3.eu-central-1.amazonaws.com/belime-estate-01JAR3JYD68D1YYR0BN7HHMAZE.png',
product_title: 'Belime Estate',
variant_title: 'Linen / Red',
total: 1500,
quantity: 1,
variant_option_values: {
Material: 'Linen',
Color: 'Red',
},
},
],
shipping_total: 100,
subtotal: 1400,
total: 1500,
tax_total: 100,
},
} satisfies OrderPlacedEmailProps;
================================================
FILE: medusa/src/modules/resend/emails/order-update.tsx
================================================
// External packages
import { Text, Heading, Button } from '@react-email/components';
// Types
import { CustomerDTO, OrderDTO } from '@medusajs/framework/types';
// Components
import EmailLayout, { EmailLayoutProps } from './components/EmailLayout';
type Props = {
customer: Pick<CustomerDTO, 'id' | 'email' | 'first_name' | 'last_name'>;
order: Pick<OrderDTO, 'id' | 'display_id'>;
};
export default function OrderUpdateEmail({
customer,
order,
...emailLayoutProps
}: Props & EmailLayoutProps) {
return (
<EmailLayout {...emailLayoutProps}>
<Heading className="text-2xl mt-0 mb-10 font-medium">
Shipping update
</Heading>
<Text className="text-md !mb-8">
Great news! Your order #{order.display_id} is now on its way to you.
<br />
Here are the shipping details.
</Text>
<Text className="text-md !mb-10">
You can track your package by clicking below:
</Text>
<Button
href={`${
process.env.STOREFRONT_URL || 'http://localhost:8000'
}/account/my-orders/${order.id}`}
className="inline-flex items-center rounded-xs justify-center bg-black text-white h-10 px-6 mb-10"
>
Order details
</Button>
<Text className="text-md m-0">
Thank you for choosing Sofa Society. We're excited for your new
sofa to find its home with you!
</Text>
</EmailLayout>
);
}
OrderUpdateEmail.PreviewProps = {
customer: {
id: '1',
email: 'example@medusa.local',
first_name: 'John',
last_name: 'Doe',
},
order: {
id: 'order_01JCNYH6VADAK90W7CBSPV5BT6',
display_id: 1,
},
} satisfies Props;
================================================
FILE: medusa/src/modules/resend/emails/welcome.tsx
================================================
// External packages
import { Text, Heading, Row, Column } from '@react-email/components';
import { CustomerDTO } from '@medusajs/framework/types';
// Components
import EmailLayout, { EmailLayoutProps } from './components/EmailLayout';
const UnorderedList: React.FC<{
children?: React.ReactNode;
className?: string;
}> = ({ children, className }) => {
return (
<Row className={['align-top', className].filter(Boolean).join(' ')}>
<Column className="pl-6">{children}</Column>
</Row>
);
};
const UnorderedListItem: React.FC<{
children?: React.ReactNode;
className?: string;
textClassName?: string;
}> = ({ children, className, textClassName }) => {
return (
<ul
role="presentation"
className={['list-disc mt-0 mb-0 p-0', className]
.filter(Boolean)
.join(' ')}
>
<li role="listitem" className="m-0 p-0">
<span className={textClassName}>{children}</span>
</li>
</ul>
);
};
type Props = {
customer: Pick<CustomerDTO, 'id' | 'email' | 'first_name' | 'last_name'>;
};
export default function WelcomeEmail({
customer,
...emailLayoutProps
}: Props & EmailLayoutProps) {
return (
<EmailLayout {...emailLayoutProps}>
<Heading className="text-2xl mt-0 mb-10 font-medium">
Welcome to Sofa Society!
</Heading>
<Text className="text-md !mb-8">
Welcome to Sofa Society! We're excited to have you join our community of
comfort enthusiasts. With our carefully crafted sofas, you're just
steps away from adding elegance and coziness to your living space.
</Text>
<Text className="text-md font-semibold !mb-8">
As a new member, here's what you can expect:
</Text>
<UnorderedList className="mb-8">
<UnorderedListItem className="text-md">
Premium, high-quality sofas in a range of styles and materials
</UnorderedListItem>
<UnorderedListItem className="text-md">
Dedicated customer support ready to assist you
</UnorderedListItem>
<UnorderedListItem className="text-md">
Exclusive offers and early access to new collections
</UnorderedListItem>
<UnorderedListItem className="text-md">
Explore our collections and find the sofa that suits your style!
</UnorderedListItem>
</UnorderedList>
<Text className="text-md">
Best wishes,
<br />
The Sofa Society Team
</Text>
</EmailLayout>
);
}
WelcomeEmail.PreviewProps = {
customer: {
id: '1',
email: 'example@medusa.local',
first_name: 'John',
last_name: 'Doe',
},
} satisfies Props;
================================================
FILE: medusa/src/modules/resend/index.ts
================================================
import { ModuleProvider, Modules } from '@medusajs/framework/utils';
import ResendNotificationProviderService from './service';
export default ModuleProvider(Modules.NOTIFICATION, {
services: [ResendNotificationProviderService],
});
================================================
FILE: medusa/src/modules/resend/service.tsx
================================================
import { AbstractNotificationProviderService } from '@medusajs/framework/utils';
import { Logger } from '@medusajs/medusa';
import {
ProviderSendNotificationDTO,
ProviderSendNotificationResultsDTO,
} from '@medusajs/types';
import { Resend } from 'resend';
import emails, { subjects } from './emails';
import type { EmailLayoutProps } from './emails/components/EmailLayout';
type InjectedDependencies = {
logger: Logger;
};
export default class ResendNotificationProviderService extends AbstractNotificationProviderService {
public static identifier = 'resend';
private resendClient: Resend;
private from: string;
private layoutOptions?: EmailLayoutProps;
private logger: Logger;
constructor({ logger }: InjectedDependencies, options: unknown) {
super();
if (
typeof options !== 'object' ||
options === null ||
!('api_key' in options) ||
typeof options.api_key !== 'string' ||
!('from' in options) ||
typeof options.from !== 'string'
) {
throw new Error(
`Invalid options provided to Resend module. Expected { api_key: string, from: string }`,
);
}
const layoutOptions: EmailLayoutProps = {};
if ('siteTitle' in options && typeof options.siteTitle === 'string') {
layoutOptions.siteTitle = options.siteTitle;
}
if ('companyName' in options && typeof options.companyName === 'string') {
layoutOptions.companyName = options.companyName;
}
if ('footerLinks' in options) {
if (
!Array.isArray(options.footerLinks) ||
!options.footerLinks.every(
(l) => typeof l.url === 'string' && typeof l.label === 'string',
)
) {
this.logger.warn(
`Invalid footer links provided to Resend module. Expected an array of { url: string, label: string } objects.`,
);
} else {
layoutOptions.footerLinks = options.footerLinks;
}
}
this.resendClient = new Resend(options.api_key);
this.from = options.from;
this.logger = logger;
this.layoutOptions = layoutOptions;
}
async send(
notification: ProviderSendNotificationDTO,
): Promise<ProviderSendNotificationResultsDTO> {
const Template = emails[notification.template];
const subject = subjects[notification.template] || '';
if (!Template) {
this.logger.error(
`Couldn't find an email template for ${
notification.template
}. The valid options are ${Object.keys(emails).join(', ')}`,
);
return {};
}
if (!subject) {
this.logger.warn(
`No subject found for template ${notification.template}. Please add a subject to the emails file.`,
);
}
const { data, error } = await this.resendClient.emails.send({
from: this.from,
to: [notification.to],
subject,
react: <Template {...this.layoutOptions} {...notification.data} />,
});
if (error) {
this.logger.error(`Failed to send email`, error);
return {};
}
return { id: data.id };
}
}
================================================
FILE: medusa/src/scripts/README.md
================================================
# Custom CLI Script
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.
## How to Create a Custom CLI Script?
To create a custom CLI script, create a TypeScript or JavaScript file under the `src/scripts` directory. The file must default export a function.
For example, create the file `src/scripts/my-script.ts` with the following content:
```ts title="src/scripts/my-script.ts"
import {
ExecArgs,
IProductModuleService
} from "@medusajs/framework/types"
import { Modules } from "@medusajs/framework/utils"
export default async function myScript ({
container
}: ExecArgs) {
const productModuleService: IProductModuleService =
container.resolve(Modules.PRODUCT)
const [, count] = await productModuleService.listAndCount()
console.log(`You have ${count} product(s)`)
}
```
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.
---
## How to Run Custom CLI Script?
To run the custom CLI script, run the `exec` command:
```bash
npx medusa exec ./src/scripts/my-script.ts
```
---
## Custom CLI Script Arguments
Your script can accept arguments from the command line. Arguments are passed to the function's object parameter in the `args` property.
For example:
```ts
import { ExecArgs } from "@medusajs/framework/types"
export default async function myScript ({
args
}: ExecArgs) {
console.log(`The arguments you passed: ${args}`)
}
```
Then, pass the arguments in the `exec` command after the file path:
```bash
npx medusa exec ./src/scripts/my-script.ts arg1 arg2
```
================================================
FILE: medusa/src/scripts/index-products.ts
================================================
import { ExecArgs, ISearchService } from '@medusajs/framework/types';
import { Modules } from '@medusajs/framework/utils';
export default async function indexProducts({ container }: ExecArgs) {
const logger = container.resolve('logger');
const meilisearchService = container.resolve(
'meilisearchService',
) as ISearchService;
const productModuleService = container.resolve(Modules.PRODUCT);
const [products, count] = await productModuleService.listAndCountProducts(
undefined,
{
relations: [
'variants',
'options',
'tags',
'collection',
'type',
'images',
'categories',
],
},
);
logger.info(`Adding ${count} products to MeiliSearch...`);
await meilisearchService.addDocuments('products', products, 'products');
logger.info('Products added to MeiliSearch');
}
================================================
FILE: medusa/src/scripts/seed.ts
================================================
import {
createApiKeysWorkflow,
createCollectionsWorkflow,
createProductCategoriesWorkflow,
createProductsWorkflow,
createProductTypesWorkflow,
createRegionsWorkflow,
createSalesChannelsWorkflow,
createShippingOptionsWorkflow,
createShippingProfilesWorkflow,
createStockLocationsWorkflow,
createTaxRegionsWorkflow,
linkSalesChannelsToApiKeyWorkflow,
linkSalesChannelsToStockLocationWorkflow,
updateStoresWorkflow,
uploadFilesWorkflow,
} from '@medusajs/medusa/core-flows';
import {
ExecArgs,
IFulfillmentModuleService,
ISalesChannelModuleService,
IStoreModuleService,
} from '@medusajs/framework/types';
import {
ContainerRegistrationKeys,
Modules,
ProductStatus,
} from '@medusajs/framework/utils';
import type FashionModuleService from '../modules/fashion/service';
import type { MaterialModelType } from '../modules/fashion/models/material';
async function getImageUrlContent(url: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch image "${url}": ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer).toString('binary');
}
export default async function seedDemoData({ container }: ExecArgs) {
const logger = container.resolve(ContainerRegistrationKeys.LOGGER);
const remoteLink = container.resolve(ContainerRegistrationKeys.LINK);
const fulfillmentModuleService: IFulfillmentModuleService = container.resolve(
Modules.FULFILLMENT,
);
const salesChannelModuleService: ISalesChannelModuleService =
container.resolve(Modules.SALES_CHANNEL);
const storeModuleService: IStoreModuleService = container.resolve(
Modules.STORE,
);
const fashionModuleService: FashionModuleService = container.resolve(
'fashionModuleService',
);
const countries = ['hr', 'gb', 'de', 'dk', 'se', 'fr', 'es', 'it'];
logger.info('Seeding store data...');
const [store] = await storeModuleService.listStores();
let defaultSalesChannel = await salesChannelModuleService.listSalesChannels({
name: 'Default Sales Channel',
});
if (!defaultSalesChannel.length) {
// create the default sales channel
const { result: salesChannelResult } = await createSalesChannelsWorkflow(
container,
).run({
input: {
salesChannelsData: [
{
name: 'Default Sales Channel',
},
],
},
});
defaultSalesChannel = salesChannelResult;
}
logger.info('Seeding region data...');
const { result: regionResult } = await createRegionsWorkflow(container).run({
input: {
regions: [
{
name: 'Europe',
currency_code: 'eur',
countries,
payment_providers: ['pp_stripe_stripe'],
},
],
},
});
const region = regionResult[0];
logger.info('Finished seeding regions.');
await updateStoresWorkflow(container).run({
input: {
selector: { id: store.id },
update: {
supported_currencies: [
{
currency_code: 'eur',
is_default: true,
},
{
currency_code: 'usd',
},
],
default_sales_channel_id: defaultSalesChannel[0].id,
default_region_id: region.id,
},
},
});
logger.info('Seeding tax regions...');
await createTaxRegionsWorkflow(container).run({
input: countries.map((country_code) => ({
country_code,
})),
});
logger.info('Finished seeding tax regions.');
logger.info('Seeding stock location data...');
const { result: stockLocationResult } = await createStockLocationsWorkflow(
container,
).run({
input: {
locations: [
{
name: 'European Warehouse',
address: {
city: 'Copenhagen',
country_code: 'DK',
address_1: '',
},
},
],
},
});
const stockLocation = stockLocationResult[0];
await remoteLink.create({
[Modules.STOCK_LOCATION]: {
stock_location_id: stockLocation.id,
},
[Modules.FULFILLMENT]: {
fulfillment_provider_id: 'manual_manual',
},
});
logger.info('Seeding fulfillment data...');
const { result: shippingProfileResult } =
await createShippingProfilesWorkflow(container).run({
input: {
data: [
{
name: 'Default',
type: 'default',
},
],
},
});
const shippingProfile = shippingProfileResult[0];
const fulfillmentSet = await fulfillmentModuleService.createFulfillmentSets({
name: 'European Warehouse delivery',
type: 'shipping',
service_zones: [
{
name: 'Europe',
geo_zones: [
{
country_code: 'hr',
type: 'country',
},
{
country_code: 'gb',
type: 'country',
},
{
country_code: 'de',
type: 'country',
},
{
country_code: 'dk',
type: 'country',
},
{
country_code: 'se',
type: 'country',
},
{
country_code: 'fr',
type: 'country',
},
{
country_code: 'es',
type: 'country',
},
{
country_code: 'it',
type: 'country',
},
],
},
],
});
await remoteLink.create({
[Modules.STOCK_LOCATION]: {
stock_location_id: stockLocation.id,
},
[Modules.FULFILLMENT]: {
fulfillment_set_id: fulfillmentSet.id,
},
});
await createShippingOptionsWorkflow(container).run({
input: [
{
name: 'Standard Shipping',
price_type: 'flat',
provider_id: 'manual_manual',
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
type: {
label: 'Standard',
description: 'Ship in 2-3 days.',
code: 'standard',
},
prices: [
{
currency_code: 'usd',
amount: 10,
},
{
currency_code: 'eur',
amount: 10,
},
{
region_id: region.id,
amount: 10,
},
],
rules: [
{
attribute: 'enabled_in_store',
value: '"true"',
operator: 'eq',
},
{
attribute: 'is_return',
value: 'false',
operator: 'eq',
},
],
},
{
name: 'Express Shipping',
price_type: 'flat',
provider_id: 'manual_manual',
service_zone_id: fulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
type: {
label: 'Express',
description: 'Ship in 24 hours.',
code: 'express',
},
prices: [
{
currency_code: 'usd',
amount: 10,
},
{
currency_code: 'eur',
amount: 10,
},
{
region_id: region.id,
amount: 10,
},
],
rules: [
{
attribute: 'enabled_in_store',
value: '"true"',
operator: 'eq',
},
{
attribute: 'is_return',
value: 'false',
operator: 'eq',
},
],
},
],
});
const pickupFulfillmentSet =
await fulfillmentModuleService.createFulfillmentSets({
name: 'Store pickup',
type: 'pickup',
service_zones: [
{
name: 'Store pickup',
geo_zones: [
{
country_code: 'hr',
type: 'country',
},
{
country_code: 'dk',
type: 'country',
},
],
},
],
});
await remoteLink.create({
[Modules.STOCK_LOCATION]: {
stock_location_id: stockLocation.id,
},
[Modules.FULFILLMENT]: {
fulfillment_set_id: pickupFulfillmentSet.id,
},
});
await createShippingOptionsWorkflow(container).run({
input: [
{
name: 'Denmark Store Pickup',
price_type: 'flat',
provider_id: 'manual_manual',
service_zone_id: pickupFulfillmentSet.service_zones[0].id,
shipping_profile_id: shippingProfile.id,
type: {
label: 'Denmark Store Pickup',
description: 'Free in-store pickup.',
code: 'standard',
},
prices: [
{
currency_code: 'usd',
amount: 0,
},
{
currency_code: 'eur',
amount: 0,
},
{
region_id: region.id,
amount: 0,
},
],
rules: [
{
attribute: 'enabled_in_store',
value: '"true"',
operator: 'eq',
},
{
attribute: 'is_return',
value: 'false',
operator: 'eq',
},
],
},
],
});
logger.info('Finished seeding fulfillment
gitextract_9kshcczh/
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ └── workflows/
│ └── node.js.yml
├── .gitignore
├── LICENSE
├── README.md
├── medusa/
│ ├── .gitignore
│ ├── .npmrc
│ ├── .vscode/
│ │ └── settings.json
│ ├── .yarnrc.yml
│ ├── README.md
│ ├── docker-compose.yml
│ ├── instrumentation.js
│ ├── integration-tests/
│ │ └── http/
│ │ ├── README.md
│ │ └── health.spec.ts
│ ├── jest.config.js
│ ├── medusa-config.js
│ ├── package.json
│ ├── src/
│ │ ├── admin/
│ │ │ ├── README.md
│ │ │ ├── components/
│ │ │ │ ├── EditMaterialDrawer.tsx
│ │ │ │ ├── Form/
│ │ │ │ │ ├── Form.tsx
│ │ │ │ │ ├── ImageField.tsx
│ │ │ │ │ ├── InputField.tsx
│ │ │ │ │ ├── SelectField.tsx
│ │ │ │ │ ├── SubmitButton.tsx
│ │ │ │ │ └── TextareaField.tsx
│ │ │ │ └── QueryClientProvider.tsx
│ │ │ ├── hooks/
│ │ │ │ ├── fashion.ts
│ │ │ │ └── images.ts
│ │ │ ├── routes/
│ │ │ │ └── fashion/
│ │ │ │ ├── [id]/
│ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── tsconfig.json
│ │ │ └── widgets/
│ │ │ ├── collection-details.tsx
│ │ │ ├── product-fashion.tsx
│ │ │ └── product-type-details.tsx
│ │ ├── api/
│ │ │ ├── README.md
│ │ │ ├── admin/
│ │ │ │ ├── custom/
│ │ │ │ │ ├── collections/
│ │ │ │ │ │ └── [collectionId]/
│ │ │ │ │ │ └── details/
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ ├── index-products/
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ └── product-types/
│ │ │ │ │ └── [productTypeId]/
│ │ │ │ │ └── details/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── fashion/
│ │ │ │ │ ├── [id]/
│ │ │ │ │ │ ├── colors/
│ │ │ │ │ │ │ ├── [colorId]/
│ │ │ │ │ │ │ │ ├── restore/
│ │ │ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ ├── restore/
│ │ │ │ │ │ │ └── route.ts
│ │ │ │ │ │ └── route.ts
│ │ │ │ │ └── route.ts
│ │ │ │ └── products/
│ │ │ │ └── [id]/
│ │ │ │ └── fashion/
│ │ │ │ └── route.ts
│ │ │ ├── middlewares.ts
│ │ │ └── store/
│ │ │ └── custom/
│ │ │ ├── customer/
│ │ │ │ └── send-welcome-email/
│ │ │ │ └── route.ts
│ │ │ ├── fashion/
│ │ │ │ └── [productHandle]/
│ │ │ │ └── route.ts
│ │ │ ├── product-types/
│ │ │ │ ├── [id]/
│ │ │ │ │ └── route.ts
│ │ │ │ ├── helpers.ts
│ │ │ │ ├── middlewares.ts
│ │ │ │ ├── query-config.ts
│ │ │ │ ├── route.ts
│ │ │ │ └── validators.ts
│ │ │ └── stripe/
│ │ │ ├── get-payment-method/
│ │ │ │ └── [id]/
│ │ │ │ └── route.ts
│ │ │ └── set-payment-method/
│ │ │ └── route.ts
│ │ ├── jobs/
│ │ │ └── README.md
│ │ ├── links/
│ │ │ └── README.md
│ │ ├── modules/
│ │ │ ├── README.md
│ │ │ ├── fashion/
│ │ │ │ ├── index.ts
│ │ │ │ ├── migrations/
│ │ │ │ │ ├── .snapshot-medusa.json
│ │ │ │ │ └── Migration20241002190028.ts
│ │ │ │ ├── models/
│ │ │ │ │ ├── color.ts
│ │ │ │ │ └── material.ts
│ │ │ │ └── service.ts
│ │ │ ├── meilisearch/
│ │ │ │ ├── index.ts
│ │ │ │ ├── loader.ts
│ │ │ │ ├── service.ts
│ │ │ │ └── types.ts
│ │ │ └── resend/
│ │ │ ├── emails/
│ │ │ │ ├── auth-email-confirm.tsx
│ │ │ │ ├── auth-forgot-password.tsx
│ │ │ │ ├── auth-password-reset.tsx
│ │ │ │ ├── components/
│ │ │ │ │ └── EmailLayout.tsx
│ │ │ │ ├── index.ts
│ │ │ │ ├── order-placed.tsx
│ │ │ │ ├── order-update.tsx
│ │ │ │ └── welcome.tsx
│ │ │ ├── index.ts
│ │ │ └── service.tsx
│ │ ├── scripts/
│ │ │ ├── README.md
│ │ │ ├── index-products.ts
│ │ │ └── seed.ts
│ │ ├── subscribers/
│ │ │ ├── README.md
│ │ │ ├── auth-password-reset-notification.ts
│ │ │ ├── customer-welcome-notification.ts
│ │ │ ├── index-products.ts
│ │ │ └── order-placed-notification.ts
│ │ └── workflows/
│ │ ├── README.md
│ │ ├── emit-customer-welcome-event.ts
│ │ └── index-products.ts
│ └── tsconfig.json
└── storefront/
├── .github/
│ ├── scripts/
│ │ └── medusa-config.js
│ └── workflows/
│ └── test-e2e.yaml
├── .gitignore
├── .prettierrc
├── .yarnrc.yml
├── LICENSE
├── README.md
├── check-env-variables.js
├── e2e/
│ ├── 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
├── eslint.config.cjs
├── next-env.d.ts
├── next-sitemap.js
├── next.config.js
├── package.json
├── playwright.config.ts
├── postcss.config.js
├── src/
│ ├── app/
│ │ ├── [countryCode]/
│ │ │ ├── (checkout)/
│ │ │ │ ├── checkout/
│ │ │ │ │ ├── loading.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── layout.tsx
│ │ │ │ └── not-found.tsx
│ │ │ └── (main)/
│ │ │ ├── about/
│ │ │ │ └── page.tsx
│ │ │ ├── account/
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── loading.tsx
│ │ │ │ ├── my-orders/
│ │ │ │ │ ├── [orderId]/
│ │ │ │ │ │ └── page.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── auth/
│ │ │ │ ├── forgot-password/
│ │ │ │ │ ├── page.tsx
│ │ │ │ │ └── reset/
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── login/
│ │ │ │ │ ├── loading.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── register/
│ │ │ │ │ ├── loading.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ └── reset-password/
│ │ │ │ └── page.tsx
│ │ │ ├── cart/
│ │ │ │ ├── loading.tsx
│ │ │ │ ├── not-found.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── collections/
│ │ │ │ └── [handle]/
│ │ │ │ └── page.tsx
│ │ │ ├── cookie-policy/
│ │ │ │ └── page.tsx
│ │ │ ├── inspiration/
│ │ │ │ └── page.tsx
│ │ │ ├── layout.tsx
│ │ │ ├── not-found.tsx
│ │ │ ├── order/
│ │ │ │ └── confirmed/
│ │ │ │ └── [id]/
│ │ │ │ ├── loading.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── page.tsx
│ │ │ ├── privacy-policy/
│ │ │ │ └── page.tsx
│ │ │ ├── products/
│ │ │ │ └── [handle]/
│ │ │ │ └── page.tsx
│ │ │ ├── search/
│ │ │ │ └── page.tsx
│ │ │ ├── store/
│ │ │ │ └── page.tsx
│ │ │ └── terms-of-use/
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── not-found.tsx
│ │ └── robots.ts
│ ├── components/
│ │ ├── Button.tsx
│ │ ├── Carousel.tsx
│ │ ├── CartDrawer.tsx
│ │ ├── CartIcon.tsx
│ │ ├── CollectionsSection.tsx
│ │ ├── Dialog.tsx
│ │ ├── Drawer.tsx
│ │ ├── Footer.tsx
│ │ ├── Forms.tsx
│ │ ├── Header.tsx
│ │ ├── HeaderDrawer.tsx
│ │ ├── HeaderWrapper.tsx
│ │ ├── Icon.tsx
│ │ ├── IconCircle.tsx
│ │ ├── InputNumberField.tsx
│ │ ├── Layout.tsx
│ │ ├── Link.tsx
│ │ ├── LocalizedLink.tsx
│ │ ├── NewsletterForm.tsx
│ │ ├── NumberField.tsx
│ │ ├── ProductPageGallery.tsx
│ │ ├── RegionSwitcher.tsx
│ │ ├── SearchField.tsx
│ │ ├── icons/
│ │ │ ├── ArrowLeft.tsx
│ │ │ ├── ArrowRight.tsx
│ │ │ ├── ArrowUpRight.tsx
│ │ │ ├── Calendar.tsx
│ │ │ ├── Case.tsx
│ │ │ ├── Check.tsx
│ │ │ ├── ChevronDown.tsx
│ │ │ ├── ChevronLeft.tsx
│ │ │ ├── ChevronRight.tsx
│ │ │ ├── ChevronUp.tsx
│ │ │ ├── Close.tsx
│ │ │ ├── CreditCard.tsx
│ │ │ ├── Heart.tsx
│ │ │ ├── Info.tsx
│ │ │ ├── Loader.tsx
│ │ │ ├── MapPin.tsx
│ │ │ ├── Menu.tsx
│ │ │ ├── Minus.tsx
│ │ │ ├── Package.tsx
│ │ │ ├── Plus.tsx
│ │ │ ├── Receipt.tsx
│ │ │ ├── Search.tsx
│ │ │ ├── Sliders.tsx
│ │ │ ├── Trash.tsx
│ │ │ ├── Truck.tsx
│ │ │ ├── Undo.tsx
│ │ │ └── User.tsx
│ │ └── ui/
│ │ ├── Checkbox.tsx
│ │ ├── Modal.tsx
│ │ ├── Radio.tsx
│ │ ├── Select.tsx
│ │ ├── Skeleton.tsx
│ │ ├── Slider.tsx
│ │ ├── Tag.tsx
│ │ └── TagList.tsx
│ ├── hooks/
│ │ ├── cart.ts
│ │ ├── country-code.tsx
│ │ ├── customer.ts
│ │ └── store.tsx
│ ├── lib/
│ │ ├── config.ts
│ │ ├── constants.tsx
│ │ ├── data/
│ │ │ ├── cart.ts
│ │ │ ├── categories.ts
│ │ │ ├── collections.ts
│ │ │ ├── cookies.ts
│ │ │ ├── customer.ts
│ │ │ ├── fulfillment.ts
│ │ │ ├── orders.ts
│ │ │ ├── payment.ts
│ │ │ ├── product-types.ts
│ │ │ ├── products.ts
│ │ │ └── regions.ts
│ │ ├── search-client.ts
│ │ ├── util/
│ │ │ ├── collections.ts
│ │ │ ├── compare-addresses.ts
│ │ │ ├── enrich-line-items.ts
│ │ │ ├── env.ts
│ │ │ ├── get-precentage-diff.ts
│ │ │ ├── get-product-price.ts
│ │ │ ├── inventory.ts
│ │ │ ├── isEmpty.ts
│ │ │ ├── medusa-error.ts
│ │ │ ├── money.ts
│ │ │ ├── react-query.tsx
│ │ │ ├── repeat.ts
│ │ │ └── sort-products.ts
│ │ └── webmcp/
│ │ ├── WebMCPProvider.tsx
│ │ ├── is-supported.ts
│ │ ├── register-tools.ts
│ │ ├── tools/
│ │ │ ├── cart.ts
│ │ │ ├── checkout.ts
│ │ │ ├── products-search.ts
│ │ │ └── promotion.ts
│ │ ├── types.ts
│ │ └── utils.ts
│ ├── middleware.ts
│ ├── modules/
│ │ ├── account/
│ │ │ └── components/
│ │ │ ├── AddressMultiple.tsx
│ │ │ ├── AddressSingle.tsx
│ │ │ ├── DefaultBillingAddressSelect.tsx
│ │ │ ├── DefaultShippingAddressSelect.tsx
│ │ │ ├── DeleteAddressButton.tsx
│ │ │ ├── PersonalInfoForm.tsx
│ │ │ ├── RequestPasswordResetButton.tsx
│ │ │ ├── SidebarNav.tsx
│ │ │ ├── SignOutButton.tsx
│ │ │ └── UpsertAddressForm.tsx
│ │ ├── auth/
│ │ │ └── components/
│ │ │ ├── ForgotPasswordForm.tsx
│ │ │ ├── LoginForm.tsx
│ │ │ ├── ResetPasswordForm.tsx
│ │ │ └── SignUpForm.tsx
│ │ ├── cart/
│ │ │ ├── components/
│ │ │ │ ├── cart-totals/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── discount-code/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── empty-cart-message/
│ │ │ │ │ └── index.tsx
│ │ │ │ └── item/
│ │ │ │ └── index.tsx
│ │ │ ├── templates/
│ │ │ │ ├── index.tsx
│ │ │ │ ├── items.tsx
│ │ │ │ └── summary.tsx
│ │ │ └── utils/
│ │ │ └── getCheckoutStep.tsx
│ │ ├── checkout/
│ │ │ ├── components/
│ │ │ │ ├── addresses/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── billing_address/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── checkout-form/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── checkout-summary-wrapper/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── country-select/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── discount-code/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── email/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── error-message/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── mobile-checkout-summary-wrapper/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── payment/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── payment-button/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── payment-card-button/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── payment-container/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── payment-test/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── payment-wrapper/
│ │ │ │ │ ├── index.tsx
│ │ │ │ │ └── stripe-wrapper.tsx
│ │ │ │ ├── review/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── shipping/
│ │ │ │ │ └── index.tsx
│ │ │ │ └── shipping-address/
│ │ │ │ └── index.tsx
│ │ │ └── templates/
│ │ │ ├── checkout-summary/
│ │ │ │ └── index.tsx
│ │ │ └── mobile-checkout-summary/
│ │ │ └── index.tsx
│ │ ├── collections/
│ │ │ └── templates/
│ │ │ └── index.tsx
│ │ ├── common/
│ │ │ ├── components/
│ │ │ │ ├── cart-totals/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── delete-button/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── line-item-unit-price/
│ │ │ │ │ └── index.tsx
│ │ │ │ └── submit-button/
│ │ │ │ └── index.tsx
│ │ │ └── icons/
│ │ │ ├── bancontact.tsx
│ │ │ ├── ideal.tsx
│ │ │ ├── paypal.tsx
│ │ │ ├── placeholder-image.tsx
│ │ │ └── spinner.tsx
│ │ ├── header/
│ │ │ └── components/
│ │ │ └── LoginLink.tsx
│ │ ├── order/
│ │ │ ├── components/
│ │ │ │ ├── OrderTotals.tsx
│ │ │ │ ├── item/
│ │ │ │ │ └── index.tsx
│ │ │ │ └── payment-details/
│ │ │ │ └── index.tsx
│ │ │ └── templates/
│ │ │ └── order-completed-template.tsx
│ │ ├── products/
│ │ │ ├── components/
│ │ │ │ ├── image-gallery/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── product-actions/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── product-preview/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── product-price/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── related-products/
│ │ │ │ │ └── index.tsx
│ │ │ │ └── thumbnail/
│ │ │ │ └── index.tsx
│ │ │ └── templates/
│ │ │ ├── index.tsx
│ │ │ ├── product-actions-wrapper/
│ │ │ │ └── index.tsx
│ │ │ └── product-info/
│ │ │ └── index.tsx
│ │ ├── skeletons/
│ │ │ ├── components/
│ │ │ │ ├── skeleton-button/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── skeleton-cart-item/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── skeleton-cart-totals/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── skeleton-mobile-summary-trigger/
│ │ │ │ │ └── index.tsx
│ │ │ │ ├── skeleton-order-summary/
│ │ │ │ │ └── index.tsx
│ │ │ │ └── skeleton-product-preview/
│ │ │ │ └── index.tsx
│ │ │ └── templates/
│ │ │ ├── skeleton-account-page/
│ │ │ │ └── index.tsx
│ │ │ ├── skeleton-cart-page/
│ │ │ │ └── index.tsx
│ │ │ ├── skeleton-checkout-summary/
│ │ │ │ └── index.tsx
│ │ │ ├── skeleton-order-confirmed/
│ │ │ │ └── index.tsx
│ │ │ ├── skeleton-product-grid/
│ │ │ │ └── index.tsx
│ │ │ └── skeleton-related-products/
│ │ │ └── index.tsx
│ │ └── store/
│ │ ├── components/
│ │ │ ├── collections-slider/
│ │ │ │ └── index.tsx
│ │ │ ├── no-results.tsx/
│ │ │ │ └── index.tsx
│ │ │ ├── pagination/
│ │ │ │ └── index.tsx
│ │ │ └── refinement-list/
│ │ │ ├── category-filter/
│ │ │ │ └── index.tsx
│ │ │ ├── collection-filter/
│ │ │ │ └── index.tsx
│ │ │ ├── index.tsx
│ │ │ ├── mobile-filters/
│ │ │ │ └── index.tsx
│ │ │ ├── mobile-sort/
│ │ │ │ └── index.tsx
│ │ │ ├── sort-products/
│ │ │ │ └── index.tsx
│ │ │ └── type-filter/
│ │ │ └── index.tsx
│ │ └── templates/
│ │ ├── index.tsx
│ │ └── paginated-products.tsx
│ ├── styles/
│ │ └── globals.css
│ └── types/
│ └── icon.ts
├── tailwind.config.js
└── tsconfig.json
SYMBOL INDEX (347 symbols across 164 files)
FILE: medusa/src/admin/components/Form/Form.tsx
type FormProps (line 12) | type FormProps<T extends z.ZodType<any, any>> = UseFormProps<
FILE: medusa/src/admin/components/Form/ImageField.tsx
type ImageFieldProps (line 8) | interface ImageFieldProps {
type ImageFieldValue (line 18) | interface ImageFieldValue {
method onError (line 55) | onError(error) {
method onDropAccepted (line 69) | onDropAccepted(files) {
FILE: medusa/src/admin/components/Form/InputField.tsx
type InputFieldProps (line 4) | interface InputFieldProps {
FILE: medusa/src/admin/components/Form/SelectField.tsx
type SelectFieldProps (line 4) | interface SelectFieldProps {
FILE: medusa/src/admin/components/Form/TextareaField.tsx
type TextareaFieldProps (line 4) | interface TextareaFieldProps {
FILE: medusa/src/api/admin/custom/collections/[collectionId]/details/route.ts
function GET (line 44) | async function GET(
function POST (line 100) | async function POST(
FILE: medusa/src/api/admin/custom/index-products/route.ts
function POST (line 7) | async function POST(
FILE: medusa/src/api/admin/custom/product-types/[productTypeId]/details/route.ts
function GET (line 14) | async function GET(
function POST (line 31) | async function POST(
FILE: medusa/src/api/store/custom/product-types/validators.ts
type AdminGetProductTypeParamsType (line 8) | type AdminGetProductTypeParamsType = z.infer<
type AdminGetProductTypesParamsType (line 13) | type AdminGetProductTypesParamsType = z.infer<
FILE: medusa/src/modules/fashion/index.ts
constant FASHION_MODULE (line 4) | const FASHION_MODULE = 'fashionModuleService';
FILE: medusa/src/modules/fashion/migrations/Migration20241002190028.ts
class Migration20241002190028 (line 3) | class Migration20241002190028 extends Migration {
method up (line 5) | async up(): Promise<void> {
method down (line 14) | async down(): Promise<void> {
FILE: medusa/src/modules/fashion/models/color.ts
type ColorModelType (line 14) | type ColorModelType = InferTypeOf<typeof Color>;
FILE: medusa/src/modules/fashion/models/material.ts
type MaterialModelType (line 11) | type MaterialModelType = InferTypeOf<typeof Material>;
FILE: medusa/src/modules/fashion/service.ts
class FashionModuleService (line 5) | class FashionModuleService extends MedusaService({
FILE: medusa/src/modules/meilisearch/service.ts
class MeiliSearchService (line 8) | class MeiliSearchService extends SearchUtils.AbstractSearchService {
method constructor (line 15) | constructor(container: any, options: MeiliSearchPluginOptions) {
method createIndex (line 35) | async createIndex(
method getIndex (line 42) | getIndex(indexName: string) {
method addDocuments (line 46) | async addDocuments(
method replaceDocuments (line 60) | async replaceDocuments(
method deleteDocument (line 68) | async deleteDocument(indexName: string, documentId: string) {
method deleteAllDocuments (line 72) | async deleteAllDocuments(indexName: string) {
method search (line 76) | async search(indexName: string, query: string, options: Record<string,...
method updateSettings (line 84) | async updateSettings(
FILE: medusa/src/modules/meilisearch/types.ts
type MeiliSearchPluginOptions (line 5) | interface MeiliSearchPluginOptions {
FILE: medusa/src/modules/resend/emails/auth-email-confirm.tsx
function AuthEmailConfirm (line 7) | function AuthEmailConfirm({
FILE: medusa/src/modules/resend/emails/auth-forgot-password.tsx
type Props (line 10) | type Props = {
function AuthPasswordForgotResetEmail (line 15) | function AuthPasswordForgotResetEmail({
FILE: medusa/src/modules/resend/emails/auth-password-reset.tsx
type Props (line 10) | type Props = {
function AuthPasswordResetEmail (line 15) | function AuthPasswordResetEmail({
FILE: medusa/src/modules/resend/emails/components/EmailLayout.tsx
type EmailLayoutProps (line 20) | type EmailLayoutProps = {
function EmailLayout (line 29) | function EmailLayout(
FILE: medusa/src/modules/resend/emails/order-placed.tsx
type OrderPlacedEmailProps (line 16) | type OrderPlacedEmailProps = {
function OrderPlacedEmail (line 75) | function OrderPlacedEmail({
FILE: medusa/src/modules/resend/emails/order-update.tsx
type Props (line 10) | type Props = {
function OrderUpdateEmail (line 15) | function OrderUpdateEmail({
FILE: medusa/src/modules/resend/emails/welcome.tsx
type Props (line 38) | type Props = {
function WelcomeEmail (line 42) | function WelcomeEmail({
FILE: medusa/src/modules/resend/service.tsx
type InjectedDependencies (line 11) | type InjectedDependencies = {
class ResendNotificationProviderService (line 15) | class ResendNotificationProviderService extends AbstractNotificationProv...
method constructor (line 22) | constructor({ logger }: InjectedDependencies, options: unknown) {
method send (line 69) | async send(
FILE: medusa/src/scripts/index-products.ts
function indexProducts (line 4) | async function indexProducts({ container }: ExecArgs) {
FILE: medusa/src/scripts/seed.ts
function getImageUrlContent (line 32) | async function getImageUrlContent(url: string) {
function seedDemoData (line 44) | async function seedDemoData({ container }: ExecArgs) {
FILE: medusa/src/subscribers/auth-password-reset-notification.ts
function sendPasswordResetNotification (line 5) | async function sendPasswordResetNotification({
FILE: medusa/src/subscribers/customer-welcome-notification.ts
function sendCustomerWelcomeNotification (line 5) | async function sendCustomerWelcomeNotification({
FILE: medusa/src/subscribers/index-products.ts
function indexProductHandler (line 5) | async function indexProductHandler({
FILE: medusa/src/subscribers/order-placed-notification.ts
type Country (line 9) | type Country = {
type MathBNInput (line 15) | type MathBNInput = Parameters<typeof MathBN.convert>[0];
function sendOrderConfirmationHandler (line 61) | async function sendOrderConfirmationHandler({
FILE: storefront/.github/scripts/medusa-config.js
constant ADMIN_CORS (line 7) | const ADMIN_CORS = `${
constant STORE_CORS (line 15) | const STORE_CORS = `${
constant DATABASE_URL (line 21) | const DATABASE_URL =
constant REDIS_URL (line 24) | const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379"
FILE: storefront/check-env-variables.js
function checkEnvVariables (line 31) | function checkEnvVariables() {
FILE: storefront/e2e/data/reset.ts
function getDatabaseClient (line 3) | async function getDatabaseClient() {
function getEnv (line 11) | function getEnv() {
function testEnvChecks (line 32) | async function testEnvChecks() {
function createTemplateDatabase (line 48) | async function createTemplateDatabase(client: Client) {
function createTestDatabase (line 72) | async function createTestDatabase(client: Client) {
function resetDatabase (line 91) | async function resetDatabase() {
function dropTemplate (line 98) | async function dropTemplate() {
FILE: storefront/e2e/data/seed.ts
function seedData (line 6) | async function seedData() {
function seedUser (line 13) | async function seedUser(email?: string, password?: string) {
function loadRegion (line 37) | async function loadRegion(axios: AxiosInstance) {
function getOrInitAxios (line 42) | async function getOrInitAxios(axios?: AxiosInstance) {
function seedGiftcard (line 52) | async function seedGiftcard(axios?: AxiosInstance) {
function seedDiscount (line 68) | async function seedDiscount(axios?: AxiosInstance) {
function loginAdmin (line 89) | async function loginAdmin() {
FILE: storefront/e2e/fixtures/account/account-page.ts
class AccountPage (line 4) | class AccountPage extends BasePage {
method constructor (line 22) | constructor(page: Page) {
method goto (line 41) | async goto() {
FILE: storefront/e2e/fixtures/account/addresses-page.ts
class AddressesPage (line 5) | class AddressesPage extends AccountPage {
method constructor (line 12) | constructor(page: Page) {
method getAddressContainer (line 21) | getAddressContainer(text: string) {
method goto (line 37) | async goto() {
FILE: storefront/e2e/fixtures/account/login-page.ts
class LoginPage (line 4) | class LoginPage extends BasePage {
method constructor (line 12) | constructor(page: Page) {
method goto (line 22) | async goto() {
FILE: storefront/e2e/fixtures/account/modals/address-modal.ts
class AddressModal (line 4) | class AddressModal extends BaseModal {
method constructor (line 19) | constructor(page: Page, modalType: "add" | "edit") {
FILE: storefront/e2e/fixtures/account/order-page.ts
class OrderPage (line 4) | class OrderPage extends AccountPage {
method constructor (line 27) | constructor(page: Page) {
method getProduct (line 63) | async getProduct(title: string, variant: string) {
FILE: storefront/e2e/fixtures/account/orders-page.ts
class OrdersPage (line 4) | class OrdersPage extends AccountPage {
method constructor (line 11) | constructor(page: Page) {
method getOrderById (line 23) | async getOrderById(orderId: string) {
method goto (line 52) | async goto() {
FILE: storefront/e2e/fixtures/account/overview-page.ts
class OverviewPage (line 4) | class OverviewPage extends AccountPage {
method constructor (line 14) | constructor(page: Page) {
method getOrder (line 28) | async getOrder(orderId: string) {
method goto (line 42) | async goto() {
FILE: storefront/e2e/fixtures/account/profile-page.ts
class ProfilePage (line 5) | class ProfilePage extends AccountPage {
method constructor (line 63) | constructor(page: Page) {
method getEditorInputs (line 141) | async getEditorInputs(editor: Locator) {
method goto (line 161) | async goto() {
FILE: storefront/e2e/fixtures/account/register-page.ts
class RegisterPage (line 4) | class RegisterPage extends BasePage {
method constructor (line 15) | constructor(page: Page) {
FILE: storefront/e2e/fixtures/base/base-modal.ts
class BaseModal (line 3) | class BaseModal {
method constructor (line 8) | constructor(page: Page, container: Locator) {
method close (line 14) | async close() {
method isOpen (line 19) | async isOpen() {
FILE: storefront/e2e/fixtures/base/base-page.ts
class BasePage (line 6) | class BasePage {
method constructor (line 17) | constructor(page: Page) {
method clickCategoryLink (line 29) | async clickCategoryLink(category: string) {
FILE: storefront/e2e/fixtures/base/cart-dropdown.ts
class CartDropdown (line 3) | class CartDropdown {
method constructor (line 10) | constructor(page: Page) {
method displayCart (line 18) | async displayCart() {
method close (line 22) | async close() {
method getCartItem (line 33) | async getCartItem(name: string, variant: string) {
FILE: storefront/e2e/fixtures/base/nav-menu.ts
class NavMenu (line 3) | class NavMenu {
method constructor (line 17) | constructor(page: Page) {
method selectShippingCountry (line 32) | async selectShippingCountry(country: string) {
method open (line 51) | async open() {
FILE: storefront/e2e/fixtures/base/search-modal.ts
class SearchModal (line 5) | class SearchModal extends BaseModal {
method constructor (line 12) | constructor(page: Page) {
method open (line 23) | async open() {
method close (line 30) | async close() {
FILE: storefront/e2e/fixtures/cart-page.ts
class CartPage (line 4) | class CartPage extends BasePage {
method constructor (line 30) | constructor(page: Page) {
method getProduct (line 70) | async getProduct(title: string, variant: string) {
method getGiftCard (line 89) | async getGiftCard(code: string) {
method getDiscount (line 103) | async getDiscount(code: string) {
method goto (line 115) | async goto() {
FILE: storefront/e2e/fixtures/category-page.ts
class CategoryPage (line 4) | class CategoryPage extends BasePage {
method constructor (line 14) | constructor(page: Page) {
method getProduct (line 25) | async getProduct(name: string) {
method sortBy (line 35) | async sortBy(sortString: string) {
FILE: storefront/e2e/fixtures/checkout-page.ts
class CheckoutPage (line 4) | class CheckoutPage extends BasePage {
method constructor (line 85) | constructor(page: Page) {
method selectSavedAddress (line 248) | async selectSavedAddress(address: string) {
method selectDeliveryOption (line 266) | async selectDeliveryOption(option: string) {
method getGiftCard (line 270) | async getGiftCard(code: string) {
method getDiscount (line 284) | async getDiscount(code: string) {
FILE: storefront/e2e/fixtures/modals/mobile-actions-modal.ts
class MobileActionsModal (line 4) | class MobileActionsModal extends BaseModal {
method constructor (line 7) | constructor(page: Page) {
method getOption (line 12) | getOption(option: string) {
method selectOption (line 18) | async selectOption(option: string) {
FILE: storefront/e2e/fixtures/order-page.ts
class OrderPage (line 4) | class OrderPage extends BasePage {
method constructor (line 32) | constructor(page: Page) {
method getProduct (line 75) | async getProduct(title: string, variant: string) {
FILE: storefront/e2e/fixtures/product-page.ts
class ProductPage (line 5) | class ProductPage extends BasePage {
method constructor (line 19) | constructor(page: Page) {
method clickAddProduct (line 40) | async clickAddProduct() {
method selectOption (line 45) | async selectOption(option: string) {
FILE: storefront/e2e/fixtures/store-page.ts
class StorePage (line 4) | class StorePage extends CategoryPage {
method constructor (line 7) | constructor(page: Page) {
method goto (line 12) | async goto() {
FILE: storefront/e2e/utils/index.ts
function getFloatValue (line 1) | function getFloatValue(s: string) {
function compareFloats (line 5) | function compareFloats(f1: number, f2: number) {
FILE: storefront/e2e/utils/locators.ts
function getSelectedOptionText (line 3) | async function getSelectedOptionText(page: Page, select: Locator) {
FILE: storefront/playwright.config.ts
constant STORAGE_STATE (line 5) | const STORAGE_STATE = path.join(__dirname, "playwright/.auth/user.json")
FILE: storefront/src/app/[countryCode]/(checkout)/checkout/loading.tsx
function Loading (line 3) | function Loading() {
FILE: storefront/src/app/[countryCode]/(checkout)/checkout/page.tsx
function Checkout (line 11) | async function Checkout({
FILE: storefront/src/app/[countryCode]/(checkout)/layout.tsx
function CheckoutLayout (line 15) | function CheckoutLayout({
FILE: storefront/src/app/[countryCode]/(checkout)/not-found.tsx
function NotFound (line 10) | async function NotFound() {
FILE: storefront/src/app/[countryCode]/(main)/about/page.tsx
function generateStaticParams (line 12) | async function generateStaticParams() {
function AboutPage (line 33) | function AboutPage() {
FILE: storefront/src/app/[countryCode]/(main)/account/layout.tsx
function AccountLayout (line 6) | function AccountLayout(props: { children: React.ReactNode }) {
FILE: storefront/src/app/[countryCode]/(main)/account/loading.tsx
function Loading (line 3) | function Loading() {
FILE: storefront/src/app/[countryCode]/(main)/account/my-orders/[orderId]/page.tsx
function AccountOrderPage (line 88) | async function AccountOrderPage({
FILE: storefront/src/app/[countryCode]/(main)/account/my-orders/page.tsx
type PageProps (line 74) | type PageProps = {
constant ORDERS_PER_PAGE (line 80) | const ORDERS_PER_PAGE = 6
function AccountMyOrdersPage (line 82) | async function AccountMyOrdersPage({ searchParams }: PageProps) {
FILE: storefront/src/app/[countryCode]/(main)/account/page.tsx
function AccountPersonalAndSecurityPage (line 24) | async function AccountPersonalAndSecurityPage({
FILE: storefront/src/app/[countryCode]/(main)/auth/forgot-password/page.tsx
function ForgotPasswordPage (line 10) | function ForgotPasswordPage() {
FILE: storefront/src/app/[countryCode]/(main)/auth/forgot-password/reset/page.tsx
function ResetPasswordPage (line 12) | async function ResetPasswordPage({
FILE: storefront/src/app/[countryCode]/(main)/auth/login/loading.tsx
function LoginLoadingPage (line 7) | async function LoginLoadingPage() {
FILE: storefront/src/app/[countryCode]/(main)/auth/login/page.tsx
function LoginPage (line 14) | async function LoginPage({
FILE: storefront/src/app/[countryCode]/(main)/auth/register/loading.tsx
function RegisterLoadingPage (line 6) | function RegisterLoadingPage() {
FILE: storefront/src/app/[countryCode]/(main)/auth/register/page.tsx
function RegisterPage (line 14) | async function RegisterPage({
FILE: storefront/src/app/[countryCode]/(main)/auth/reset-password/page.tsx
function ResetPasswordPage (line 12) | async function ResetPasswordPage({
FILE: storefront/src/app/[countryCode]/(main)/cart/loading.tsx
function Loading (line 3) | function Loading() {
FILE: storefront/src/app/[countryCode]/(main)/cart/not-found.tsx
function NotFound (line 10) | function NotFound() {
FILE: storefront/src/app/[countryCode]/(main)/cart/page.tsx
function Cart (line 8) | function Cart() {
FILE: storefront/src/app/[countryCode]/(main)/collections/[handle]/page.tsx
type Props (line 14) | type Props = {
function generateStaticParams (line 24) | async function generateStaticParams() {
function generateMetadata (line 55) | async function generateMetadata({ params }: Props): Promise<Metadata> {
function CollectionPage (line 83) | async function CollectionPage({ params, searchParams }: Props) {
FILE: storefront/src/app/[countryCode]/(main)/cookie-policy/page.tsx
function generateStaticParams (line 10) | async function generateStaticParams() {
function CookiePolicyPage (line 31) | function CookiePolicyPage() {
FILE: storefront/src/app/[countryCode]/(main)/inspiration/page.tsx
function generateStaticParams (line 14) | async function generateStaticParams() {
function InspirationPage (line 35) | function InspirationPage() {
FILE: storefront/src/app/[countryCode]/(main)/layout.tsx
function PageLayout (line 10) | async function PageLayout(props: { children: React.ReactNode }) {
FILE: storefront/src/app/[countryCode]/(main)/not-found.tsx
function NotFound (line 10) | function NotFound() {
FILE: storefront/src/app/[countryCode]/(main)/order/confirmed/[id]/loading.tsx
function Loading (line 3) | function Loading() {
FILE: storefront/src/app/[countryCode]/(main)/order/confirmed/[id]/page.tsx
type Props (line 7) | type Props = {
function OrderConfirmedPage (line 16) | async function OrderConfirmedPage({ params }: Props) {
FILE: storefront/src/app/[countryCode]/(main)/page.tsx
function Home (line 58) | async function Home({
FILE: storefront/src/app/[countryCode]/(main)/privacy-policy/page.tsx
function generateStaticParams (line 10) | async function generateStaticParams() {
function PrivacyPolicyPage (line 31) | function PrivacyPolicyPage() {
FILE: storefront/src/app/[countryCode]/(main)/products/[handle]/page.tsx
type Props (line 12) | type Props = {
function generateStaticParams (line 16) | async function generateStaticParams() {
function generateMetadata (line 56) | async function generateMetadata({ params }: Props): Promise<Metadata> {
function ProductPage (line 81) | async function ProductPage({ params }: Props) {
FILE: storefront/src/app/[countryCode]/(main)/search/page.tsx
type Props (line 10) | type Props = {
function SearchPage (line 20) | async function SearchPage({ params, searchParams }: Props) {
FILE: storefront/src/app/[countryCode]/(main)/store/page.tsx
type Params (line 11) | type Params = {
function StorePage (line 24) | async function StorePage({ searchParams, params }: Params) {
FILE: storefront/src/app/[countryCode]/(main)/terms-of-use/page.tsx
function generateStaticParams (line 10) | async function generateStaticParams() {
function TermsOfUsePage (line 31) | function TermsOfUsePage() {
FILE: storefront/src/app/layout.tsx
function RootLayout (line 23) | function RootLayout(props: { children: React.ReactNode }) {
FILE: storefront/src/app/not-found.tsx
function NotFoundPage (line 12) | function NotFoundPage() {
FILE: storefront/src/app/robots.ts
function robots (line 3) | function robots(): MetadataRoute.Robots {
FILE: storefront/src/components/Button.tsx
type ButtonOwnProps (line 9) | type ButtonOwnProps = {
type ButtonProps (line 71) | type ButtonProps = React.ComponentPropsWithoutRef<"button"> &
FILE: storefront/src/components/Carousel.tsx
type CarouselProps (line 11) | type CarouselProps = {
FILE: storefront/src/components/Drawer.tsx
type DrawerProps (line 7) | interface DrawerProps
FILE: storefront/src/components/Forms.tsx
type FormProps (line 21) | type FormProps<T extends z.ZodType<any, any, any>> = UseFormProps<
type InputLabelOwnProps (line 122) | type InputLabelOwnProps = {
type InputSubLabelOwnProps (line 141) | type InputSubLabelOwnProps = {
type InputOwnProps (line 164) | type InputOwnProps = {
type InputFieldProps (line 228) | interface InputFieldProps {
type CountrySelectFieldProps (line 270) | interface CountrySelectFieldProps {
FILE: storefront/src/components/Icon.tsx
type IconNames (line 31) | type IconNames =
type IconProps (line 62) | type IconProps = React.ComponentPropsWithoutRef<"svg"> & {
FILE: storefront/src/components/InputNumberField.tsx
type InputNumberFieldProps (line 8) | type InputNumberFieldProps = Omit<
FILE: storefront/src/components/Layout.tsx
type BreakpointsNames (line 25) | type BreakpointsNames = (typeof breakpointsNamesArray)[number]
type ColumnsNumbers (line 26) | type ColumnsNumbers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13
type LayoutOwnProps (line 27) | type LayoutOwnProps = {
FILE: storefront/src/components/Link.tsx
type LinkOwnProps (line 5) | type LinkOwnProps = {
FILE: storefront/src/components/SearchField.tsx
type ListItem (line 18) | interface ListItem extends Hit<MeiliSearchProductHit> {
method getKey (line 46) | getKey(item) {
FILE: storefront/src/components/ui/Modal.tsx
type UiModalOwnProps (line 21) | type UiModalOwnProps = {
FILE: storefront/src/components/ui/Radio.tsx
type UiRadioOwnProps (line 6) | type UiRadioOwnProps = {
FILE: storefront/src/components/ui/Select.tsx
type UiSelectButtonOwnProps (line 7) | type UiSelectButtonOwnProps = {
FILE: storefront/src/components/ui/Skeleton.tsx
type SkeletonProps (line 3) | type SkeletonProps = {
FILE: storefront/src/components/ui/Tag.tsx
type UiTagOwnProps (line 5) | type UiTagOwnProps = {
FILE: storefront/src/hooks/cart.ts
type UpdateLineItemContext (line 70) | type UpdateLineItemContext = {
method onSuccess (line 150) | async onSuccess(...args) {
type LineItemQuantityUpdater (line 161) | type LineItemQuantityUpdater = {
type DeleteLineItemContext (line 278) | type DeleteLineItemContext = {
method onSuccess (line 343) | async onSuccess(...args) {
method onSuccess (line 376) | async onSuccess(...args) {
method onSuccess (line 408) | async onSuccess(...args) {
method onSuccess (line 474) | async onSuccess(...args) {
method onSuccess (line 502) | async onSuccess(...args) {
method onSuccess (line 532) | async onSuccess(...args) {
method onSuccess (line 560) | async onSuccess(...args) {
method onSuccess (line 613) | async onSuccess(...args) {
method onSuccess (line 636) | async onSuccess(...args) {
method onSuccess (line 662) | async onSuccess(...args) {
FILE: storefront/src/lib/config.ts
constant MEDUSA_BACKEND_URL (line 4) | let MEDUSA_BACKEND_URL = "http://localhost:9000"
FILE: storefront/src/lib/data/cart.ts
function retrieveCart (line 21) | async function retrieveCart() {
function getCartQuantity (line 45) | async function getCartQuantity() {
function getOrSetCart (line 55) | async function getOrSetCart(input: unknown) {
function updateCart (line 94) | async function updateCart(data: HttpTypes.StoreUpdateCart) {
function addToCart (line 109) | async function addToCart({
function updateLineItem (line 155) | async function updateLineItem({
function deleteLineItem (line 187) | async function deleteLineItem(lineId: unknown) {
function setShippingMethod (line 206) | async function setShippingMethod({
function setPaymentMethod (line 234) | async function setPaymentMethod(
function getPaymentMethod (line 250) | async function getPaymentMethod(id: string) {
function initiatePaymentSession (line 259) | async function initiatePaymentSession(provider_id: unknown) {
function applyPromotions (line 286) | async function applyPromotions(codes: string[]) {
function removePromotions (line 299) | async function removePromotions(codes: string[]) {
function setEmail (line 325) | async function setEmail({
function setAddresses (line 354) | async function setAddresses(
function placeOrder (line 383) | async function placeOrder() {
function updateRegion (line 410) | async function updateRegion(countryCode: string, currentPath: string) {
FILE: storefront/src/lib/data/customer.ts
function signup (line 63) | async function signup(formData: z.infer<typeof signupFormSchema>) {
function login (line 118) | async function login(formData: z.infer<typeof loginFormSchema>) {
function signout (line 148) | async function signout(countryCode: string) {
function requestPasswordReset (line 243) | async function requestPasswordReset() {
function resetPassword (line 284) | async function resetPassword(
function forgotPassword (line 337) | async function forgotPassword(
function updateDefaultShippingAddress (line 360) | async function updateDefaultShippingAddress(addressId: string) {
function updateDefaultBillingAddress (line 384) | async function updateDefaultBillingAddress(addressId: string) {
FILE: storefront/src/lib/search-client.ts
type MeiliSearchProductHit (line 8) | interface MeiliSearchProductHit {
FILE: storefront/src/lib/util/compare-addresses.ts
function compareAddresses (line 4) | function compareAddresses(
FILE: storefront/src/lib/util/enrich-line-items.ts
function enrichLineItems (line 7) | async function enrichLineItems<
FILE: storefront/src/lib/util/get-product-price.ts
function getProductPrice (line 30) | function getProductPrice({
FILE: storefront/src/lib/util/inventory.ts
function getVariantItemsInStock (line 3) | function getVariantItemsInStock(variant: HttpTypes.StoreProductVariant) {
FILE: storefront/src/lib/util/medusa-error.ts
function medusaError (line 2) | function medusaError(error: any): never {
FILE: storefront/src/lib/util/money.ts
type ConvertToLocaleParams (line 3) | type ConvertToLocaleParams = {
FILE: storefront/src/lib/util/sort-products.ts
type MinPricedProduct (line 4) | interface MinPricedProduct extends HttpTypes.StoreProduct {
function sortProducts (line 14) | function sortProducts(
FILE: storefront/src/lib/webmcp/register-tools.ts
type Navigator (line 13) | interface Navigator extends globalThis.Navigator {
type RegisterableWebMCPTool (line 41) | type RegisterableWebMCPTool = {
FILE: storefront/src/lib/webmcp/tools/cart.ts
type CartManageInput (line 15) | interface CartManageInput {
FILE: storefront/src/lib/webmcp/tools/checkout.ts
type NavigateToProductInput (line 4) | interface NavigateToProductInput {
type NavigateToResult (line 9) | type NavigateToResult = {
FILE: storefront/src/lib/webmcp/tools/products-search.ts
type ProductSearchInput (line 7) | interface ProductSearchInput {
type ProductSearchData (line 16) | interface ProductSearchData {
FILE: storefront/src/lib/webmcp/tools/promotion.ts
type PromotionInput (line 5) | interface PromotionInput {
FILE: storefront/src/lib/webmcp/types.ts
type WebMCPClient (line 3) | interface WebMCPClient {
type WebMCPToolContext (line 7) | interface WebMCPToolContext {
type WebMCPToolResult (line 12) | type WebMCPToolResult<TData> =
type WebMCPTool (line 28) | interface WebMCPTool<TInput, TData> {
type CartSnapshot (line 41) | interface CartSnapshot {
FILE: storefront/src/middleware.ts
constant BACKEND_URL (line 5) | const BACKEND_URL = process.env.NEXT_PUBLIC_MEDUSA_BACKEND_URL
constant PUBLISHABLE_API_KEY (line 6) | const PUBLISHABLE_API_KEY = process.env.NEXT_PUBLIC_MEDUSA_PUBLISHABLE_KEY
constant DEFAULT_REGION (line 7) | const DEFAULT_REGION = process.env.NEXT_PUBLIC_DEFAULT_REGION || "us"
function getRegionMap (line 14) | async function getRegionMap() {
function getCountryCode (line 54) | async function getCountryCode(
function middleware (line 91) | async function middleware(request: NextRequest) {
FILE: storefront/src/modules/cart/components/cart-totals/index.tsx
type CartTotalsProps (line 9) | type CartTotalsProps = {
FILE: storefront/src/modules/cart/components/discount-code/index.tsx
type DiscountCodeProps (line 13) | type DiscountCodeProps = {
FILE: storefront/src/modules/cart/components/item/index.tsx
type ItemProps (line 14) | type ItemProps = {
FILE: storefront/src/modules/cart/templates/items.tsx
type ItemsTemplateProps (line 5) | type ItemsTemplateProps = {
FILE: storefront/src/modules/cart/templates/summary.tsx
type SummaryProps (line 13) | type SummaryProps = {
FILE: storefront/src/modules/cart/utils/getCheckoutStep.tsx
function getCheckoutStep (line 3) | function getCheckoutStep(cart?: HttpTypes.StoreCart) {
FILE: storefront/src/modules/checkout/components/checkout-summary-wrapper/index.tsx
function CheckoutSummaryWrapper (line 7) | function CheckoutSummaryWrapper() {
FILE: storefront/src/modules/checkout/components/country-select/index.tsx
type CountrySelectProps (line 15) | type CountrySelectProps = ReactAria.SelectProps<
FILE: storefront/src/modules/checkout/components/discount-code/index.tsx
type DiscountCodeProps (line 12) | type DiscountCodeProps = {
FILE: storefront/src/modules/checkout/components/mobile-checkout-summary-wrapper/index.tsx
function MobileCheckoutSummaryWrapper (line 6) | function MobileCheckoutSummaryWrapper() {
FILE: storefront/src/modules/checkout/components/payment-button/index.tsx
type PaymentButtonProps (line 17) | type PaymentButtonProps = {
FILE: storefront/src/modules/checkout/components/payment-card-button/index.tsx
type PaymentButtonProps (line 13) | type PaymentButtonProps = {
FILE: storefront/src/modules/checkout/components/payment-container/index.tsx
type PaymentContainerProps (line 7) | type PaymentContainerProps = {
FILE: storefront/src/modules/checkout/components/payment-wrapper/index.tsx
type WrapperProps (line 12) | type WrapperProps = {
FILE: storefront/src/modules/checkout/components/payment-wrapper/stripe-wrapper.tsx
type StripeWrapperProps (line 7) | type StripeWrapperProps = {
FILE: storefront/src/modules/collections/templates/index.tsx
function CollectionTemplate (line 15) | async function CollectionTemplate({
FILE: storefront/src/modules/common/components/cart-totals/index.tsx
type CartTotalsProps (line 8) | type CartTotalsProps = {
FILE: storefront/src/modules/common/components/line-item-unit-price/index.tsx
type LineItemUnitPriceProps (line 5) | type LineItemUnitPriceProps = {
FILE: storefront/src/modules/common/components/submit-button/index.tsx
function SubmitButton (line 8) | function SubmitButton(props: Omit<ButtonProps, "type">) {
FILE: storefront/src/modules/order/components/item/index.tsx
type ItemProps (line 7) | type ItemProps = {
FILE: storefront/src/modules/order/components/payment-details/index.tsx
type PaymentDetailsProps (line 6) | type PaymentDetailsProps = {
FILE: storefront/src/modules/order/templates/order-completed-template.tsx
type OrderCompletedTemplateProps (line 10) | type OrderCompletedTemplateProps = {
function OrderCompletedTemplate (line 14) | async function OrderCompletedTemplate({
FILE: storefront/src/modules/products/components/image-gallery/index.tsx
type ImageGalleryProps (line 5) | type ImageGalleryProps = {
FILE: storefront/src/modules/products/components/product-actions/index.tsx
type ProductActionsProps (line 24) | type ProductActionsProps = {
function ProductActions (line 79) | function ProductActions({ product, materials, disabled }: ProductActions...
FILE: storefront/src/modules/products/components/product-preview/index.tsx
function ProductPreview (line 6) | function ProductPreview({
FILE: storefront/src/modules/products/components/product-price/index.tsx
function ProductPrice (line 4) | function ProductPrice({
FILE: storefront/src/modules/products/components/related-products/index.tsx
type RelatedProductsProps (line 7) | type RelatedProductsProps = {
function RelatedProducts (line 12) | async function RelatedProducts({
FILE: storefront/src/modules/products/components/thumbnail/index.tsx
type ThumbnailProps (line 8) | type ThumbnailProps = {
FILE: storefront/src/modules/products/templates/index.tsx
type ProductTemplateProps (line 15) | type ProductTemplateProps = {
FILE: storefront/src/modules/products/templates/product-actions-wrapper/index.tsx
function ProductActionsWrapper (line 8) | async function ProductActionsWrapper({
FILE: storefront/src/modules/products/templates/product-info/index.tsx
type ProductInfoProps (line 4) | type ProductInfoProps = {
FILE: storefront/src/modules/skeletons/templates/skeleton-checkout-summary/index.tsx
function SkeletonCheckoutSummary (line 3) | function SkeletonCheckoutSummary() {
FILE: storefront/src/modules/store/components/pagination/index.tsx
function Pagination (line 6) | function Pagination({
FILE: storefront/src/modules/store/components/refinement-list/index.tsx
type RefinementListProps (line 16) | type RefinementListProps = {
FILE: storefront/src/modules/store/components/refinement-list/sort-products/index.tsx
type SortOptions (line 12) | type SortOptions = "price_asc" | "price_desc" | "created_at"
type SortProductsProps (line 14) | type SortProductsProps = {
FILE: storefront/src/modules/store/templates/paginated-products.tsx
constant PRODUCT_LIMIT (line 12) | const PRODUCT_LIMIT = 12
function PaginatedProducts (line 13) | function PaginatedProducts({
FILE: storefront/src/types/icon.ts
type IconProps (line 1) | type IconProps = {
Condensed preview — 379 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (1,103K chars).
[
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 834,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Describe the b"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 595,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n**Is your fea"
},
{
"path": ".github/workflows/node.js.yml",
"chars": 1607,
"preview": "# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tes"
},
{
"path": ".gitignore",
"chars": 19,
"preview": ".DS_Store\n.agents/\n"
},
{
"path": "LICENSE",
"chars": 1062,
"preview": "MIT License\n\nCopyright (c) 2024 Agilo\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof t"
},
{
"path": "README.md",
"chars": 9192,
"preview": "<h1 align=\"center\">Fashion E-commerce Starter for Medusa 2.0</h1>\n\n<video src=\"https://github.com/user-attachments/asset"
},
{
"path": "medusa/.gitignore",
"chars": 254,
"preview": "/dist\n.env\n.DS_Store\n/uploads\n/node_modules\nyarn-error.log\n\n.idea\n\ncoverage\n\n!src/**\n\n./tsconfig.tsbuildinfo\npackage-loc"
},
{
"path": "medusa/.npmrc",
"chars": 19,
"preview": "node-linker=hoisted"
},
{
"path": "medusa/.vscode/settings.json",
"chars": 3,
"preview": "{\n}"
},
{
"path": "medusa/.yarnrc.yml",
"chars": 25,
"preview": "nodeLinker: node-modules\n"
},
{
"path": "medusa/README.md",
"chars": 3339,
"preview": "<p align=\"center\">\n <a href=\"https://www.medusajs.com\">\n <picture>\n <source media=\"(prefers-color-scheme: dark)\" sr"
},
{
"path": "medusa/docker-compose.yml",
"chars": 1399,
"preview": "services:\n postgres:\n image: postgres:16\n ports:\n - 5432:5432\n environment:\n POSTGRES_USER: postgres"
},
{
"path": "medusa/instrumentation.js",
"chars": 794,
"preview": "// Uncomment this file to enable instrumentation and observability using OpenTelemetry\n// Refer to the docs for installa"
},
{
"path": "medusa/integration-tests/http/README.md",
"chars": 851,
"preview": "# Integration Tests\n\nThe `medusa-test-utils` package provides utility functions to create integration tests for your API"
},
{
"path": "medusa/integration-tests/http/health.spec.ts",
"chars": 392,
"preview": "import { medusaIntegrationTestRunner } from '@medusajs/test-utils';\njest.setTimeout(60 * 1000);\n\nmedusaIntegrationTestRu"
},
{
"path": "medusa/jest.config.js",
"chars": 765,
"preview": "const { loadEnv } = require('@medusajs/utils')\nloadEnv('test', process.cwd())\n\nmodule.exports = {\n transform: {\n \"^."
},
{
"path": "medusa/medusa-config.js",
"chars": 6505,
"preview": "const { loadEnv, defineConfig } = require('@medusajs/framework/utils');\n\nloadEnv(process.env.NODE_ENV, process.cwd());\n\n"
},
{
"path": "medusa/package.json",
"chars": 1937,
"preview": "{\n \"name\": \"fashion-starter-medusa\",\n \"version\": \"2.0.0\",\n \"description\": \"A starter for Medusa projects.\",\n \"author"
},
{
"path": "medusa/src/admin/README.md",
"chars": 866,
"preview": "# Admin Customizations\n\nYou can extend the Medusa Admin to add widgets and new pages. Your customizations interact with "
},
{
"path": "medusa/src/admin/components/EditMaterialDrawer.tsx",
"chars": 2254,
"preview": "import * as React from 'react';\nimport { z } from 'zod';\nimport { Button, Drawer } from '@medusajs/ui';\nimport { useMuta"
},
{
"path": "medusa/src/admin/components/Form/Form.tsx",
"chars": 1410,
"preview": "import * as React from 'react';\nimport {\n FormProvider,\n useForm,\n UseFormProps,\n DefaultValues,\n UseFormReturn,\n} "
},
{
"path": "medusa/src/admin/components/Form/ImageField.tsx",
"chars": 3664,
"preview": "import { Label, Button, clx } from '@medusajs/ui';\nimport { DropzoneProps, useDropzone } from 'react-dropzone';\nimport {"
},
{
"path": "medusa/src/admin/components/Form/InputField.tsx",
"chars": 2408,
"preview": "import { Input, Label, clx } from '@medusajs/ui';\nimport { useController, ControllerRenderProps } from 'react-hook-form'"
},
{
"path": "medusa/src/admin/components/Form/SelectField.tsx",
"chars": 1537,
"preview": "import { Label, clx, Select } from '@medusajs/ui';\nimport { useController, ControllerRenderProps } from 'react-hook-form"
},
{
"path": "medusa/src/admin/components/Form/SubmitButton.tsx",
"chars": 396,
"preview": "import { Button } from '@medusajs/ui';\nimport { useFormState } from 'react-hook-form';\n\nexport const SubmitButton: React"
},
{
"path": "medusa/src/admin/components/Form/TextareaField.tsx",
"chars": 1371,
"preview": "import { Textarea, Label, clx } from '@medusajs/ui';\nimport { useController, ControllerRenderProps } from 'react-hook-fo"
},
{
"path": "medusa/src/admin/components/QueryClientProvider.tsx",
"chars": 653,
"preview": "import * as React from 'react';\nimport {\n QueryClient,\n QueryClientProvider as TanstackQueryClientProvider,\n} from '@t"
},
{
"path": "medusa/src/admin/hooks/fashion.ts",
"chars": 1956,
"preview": "import {\n useMutation,\n UseMutationOptions,\n useQueryClient,\n} from '@tanstack/react-query';\n\nexport const useCreateM"
},
{
"path": "medusa/src/admin/hooks/images.ts",
"chars": 1941,
"preview": "import { HttpTypes } from '@medusajs/framework/types';\nimport { UseMutationOptions, useMutation } from '@tanstack/react-"
},
{
"path": "medusa/src/admin/routes/fashion/[id]/page.tsx",
"chars": 16289,
"preview": "import * as React from 'react';\nimport { z } from 'zod';\nimport { useParams } from 'react-router-dom';\nimport {\n Contai"
},
{
"path": "medusa/src/admin/routes/fashion/page.tsx",
"chars": 11747,
"preview": "import * as React from 'react';\nimport { defineRouteConfig } from '@medusajs/admin-sdk';\nimport {\n Swatch,\n PencilSqua"
},
{
"path": "medusa/src/admin/tsconfig.json",
"chars": 549,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ES2020\",\n \"useDefineForClassFields\": true,\n \"lib\": [\"ES2020\", \"DOM\", \"DOM."
},
{
"path": "medusa/src/admin/widgets/collection-details.tsx",
"chars": 9562,
"preview": "import * as React from 'react';\nimport { defineWidgetConfig } from '@medusajs/admin-sdk';\nimport { DetailWidgetProps, Ad"
},
{
"path": "medusa/src/admin/widgets/product-fashion.tsx",
"chars": 10699,
"preview": "import * as React from 'react';\nimport { defineWidgetConfig } from '@medusajs/admin-sdk';\nimport { DetailWidgetProps, Ad"
},
{
"path": "medusa/src/admin/widgets/product-type-details.tsx",
"chars": 4508,
"preview": "import * as React from 'react';\nimport { defineWidgetConfig } from '@medusajs/admin-sdk';\nimport { DetailWidgetProps, Ad"
},
{
"path": "medusa/src/api/README.md",
"chars": 3749,
"preview": "# Custom API Routes\n\nAn API Route is a REST API endpoint.\n\nAn API Route is created in a TypeScript or JavaScript file un"
},
{
"path": "medusa/src/api/admin/custom/collections/[collectionId]/details/route.ts",
"chars": 3648,
"preview": "import { Modules } from '@medusajs/framework/utils';\nimport { MedusaRequest, MedusaResponse } from '@medusajs/framework'"
},
{
"path": "medusa/src/api/admin/custom/index-products/route.ts",
"chars": 357,
"preview": "import {\n AuthenticatedMedusaRequest,\n MedusaResponse,\n} from '@medusajs/framework';\nimport { indexProductsWorkflow } "
},
{
"path": "medusa/src/api/admin/custom/product-types/[productTypeId]/details/route.ts",
"chars": 1439,
"preview": "import { Modules } from '@medusajs/framework/utils';\nimport { MedusaRequest, MedusaResponse } from '@medusajs/framework'"
},
{
"path": "medusa/src/api/admin/fashion/[id]/colors/[colorId]/restore/route.ts",
"chars": 689,
"preview": "import { MedusaRequest, MedusaResponse } from '@medusajs/framework';\nimport FashionModuleService from '../../../../../.."
},
{
"path": "medusa/src/api/admin/fashion/[id]/colors/[colorId]/route.ts",
"chars": 1949,
"preview": "import { MedusaRequest, MedusaResponse } from '@medusajs/framework';\nimport { z } from '@medusajs/framework/zod';\nimport"
},
{
"path": "medusa/src/api/admin/fashion/[id]/colors/route.ts",
"chars": 1665,
"preview": "import { z } from '@medusajs/framework/zod';\nimport { MedusaRequest, MedusaResponse } from '@medusajs/framework';\nimport"
},
{
"path": "medusa/src/api/admin/fashion/[id]/restore/route.ts",
"chars": 613,
"preview": "import { MedusaRequest, MedusaResponse } from '@medusajs/framework';\nimport FashionModuleService from '../../../../../mo"
},
{
"path": "medusa/src/api/admin/fashion/[id]/route.ts",
"chars": 1535,
"preview": "import { MedusaRequest, MedusaResponse } from '@medusajs/framework';\nimport { z } from '@medusajs/framework/zod';\nimport"
},
{
"path": "medusa/src/api/admin/fashion/route.ts",
"chars": 1548,
"preview": "import { z } from '@medusajs/framework/zod';\nimport { MedusaRequest, MedusaResponse } from '@medusajs/framework';\nimport"
},
{
"path": "medusa/src/api/admin/products/[id]/fashion/route.ts",
"chars": 2532,
"preview": "import { MedusaRequest, MedusaResponse } from '@medusajs/framework';\nimport { Modules } from '@medusajs/framework/utils'"
},
{
"path": "medusa/src/api/middlewares.ts",
"chars": 417,
"preview": "import { defineMiddlewares } from '@medusajs/medusa';\nimport { adminProductTypeRoutesMiddlewares } from './store/custom/"
},
{
"path": "medusa/src/api/store/custom/customer/send-welcome-email/route.ts",
"chars": 468,
"preview": "import {\n AuthenticatedMedusaRequest,\n MedusaResponse,\n} from '@medusajs/framework';\nimport emitCustomerWelcomeEvent f"
},
{
"path": "medusa/src/api/store/custom/fashion/[productHandle]/route.ts",
"chars": 2512,
"preview": "import { MedusaRequest, MedusaResponse } from '@medusajs/framework';\nimport { Modules } from '@medusajs/framework/utils'"
},
{
"path": "medusa/src/api/store/custom/product-types/[id]/route.ts",
"chars": 590,
"preview": "import { refetchProductType } from '../helpers';\nimport { AdminGetProductTypeParamsType } from '../validators';\nimport {"
},
{
"path": "medusa/src/api/store/custom/product-types/helpers.ts",
"chars": 413,
"preview": "import { MedusaContainer, ProductTypeDTO } from \"@medusajs/framework/types\"\n\nexport const refetchProductType = async (\n "
},
{
"path": "medusa/src/api/store/custom/product-types/middlewares.ts",
"chars": 782,
"preview": "import * as QueryConfig from './query-config';\nimport {\n MiddlewareRoute,\n validateAndTransformQuery,\n} from '@medusaj"
},
{
"path": "medusa/src/api/store/custom/product-types/query-config.ts",
"chars": 359,
"preview": "export const defaultAdminProductTypeFields = [\n \"id\",\n \"value\",\n \"created_at\",\n \"updated_at\",\n]\n\nexport const retrie"
},
{
"path": "medusa/src/api/store/custom/product-types/route.ts",
"chars": 719,
"preview": "import { HttpTypes, ProductTypeDTO } from '@medusajs/framework/types';\nimport {\n AuthenticatedMedusaRequest,\n MedusaRe"
},
{
"path": "medusa/src/api/store/custom/product-types/validators.ts",
"chars": 1072,
"preview": "import {\n createSelectParams,\n createFindParams,\n createOperatorMap,\n} from '@medusajs/medusa/api/utils/validators';\n"
},
{
"path": "medusa/src/api/store/custom/stripe/get-payment-method/[id]/route.ts",
"chars": 375,
"preview": "import { MedusaResponse, MedusaStoreRequest } from \"@medusajs/framework\";\nimport Stripe from \"stripe\";\n\nconst stripe = n"
},
{
"path": "medusa/src/api/store/custom/stripe/set-payment-method/route.ts",
"chars": 1286,
"preview": "import { MedusaResponse, MedusaStoreRequest } from \"@medusajs/framework\";\nimport { IPaymentModuleService } from \"@medusa"
},
{
"path": "medusa/src/jobs/README.md",
"chars": 1370,
"preview": "# Custom scheduled jobs\n\nA scheduled job is a function executed at a specified interval of time in the background of you"
},
{
"path": "medusa/src/links/README.md",
"chars": 657,
"preview": "# Module Links\n\nA module link forms an association between two data models of different modules, while maintaining modul"
},
{
"path": "medusa/src/modules/README.md",
"chars": 2246,
"preview": "# Custom Module\n\nA module is a package of reusable functionalities. It can be integrated into your Medusa application wi"
},
{
"path": "medusa/src/modules/fashion/index.ts",
"chars": 231,
"preview": "import { Module } from '@medusajs/framework/utils';\nimport FashionModuleService from './service';\n\nexport const FASHION_"
},
{
"path": "medusa/src/modules/fashion/migrations/.snapshot-medusa.json",
"chars": 4673,
"preview": "{\n \"namespaces\": [\n \"public\"\n ],\n \"name\": \"public\",\n \"tables\": [\n {\n \"columns\": {\n \"id\": {\n "
},
{
"path": "medusa/src/modules/fashion/migrations/Migration20241002190028.ts",
"chars": 1308,
"preview": "import { Migration } from '@mikro-orm/migrations';\n\nexport class Migration20241002190028 extends Migration {\n\n async up"
},
{
"path": "medusa/src/modules/fashion/models/color.ts",
"chars": 421,
"preview": "import { model } from '@medusajs/framework/utils';\nimport { InferTypeOf } from '@medusajs/framework/types';\nimport Mater"
},
{
"path": "medusa/src/modules/fashion/models/material.ts",
"chars": 366,
"preview": "import { model } from '@medusajs/framework/utils';\nimport { InferTypeOf } from '@medusajs/framework/types';\nimport Color"
},
{
"path": "medusa/src/modules/fashion/service.ts",
"chars": 231,
"preview": "import { MedusaService } from '@medusajs/framework/utils';\nimport Material from './models/material';\nimport Color from '"
},
{
"path": "medusa/src/modules/meilisearch/index.ts",
"chars": 224,
"preview": "import { Module } from '@medusajs/utils';\nimport Loader from './loader';\nimport { MeiliSearchService } from './service';"
},
{
"path": "medusa/src/modules/meilisearch/loader.ts",
"chars": 783,
"preview": "import { LoaderOptions } from '@medusajs/types';\nimport { MeiliSearchService } from './service';\nimport { MeiliSearchPlu"
},
{
"path": "medusa/src/modules/meilisearch/service.ts",
"chars": 2990,
"preview": "import { SearchTypes } from '@medusajs/types';\nimport { SearchUtils } from '@medusajs/utils';\n// @ts-ignore\nimport { Mei"
},
{
"path": "medusa/src/modules/meilisearch/types.ts",
"chars": 372,
"preview": "import { SearchTypes } from '@medusajs/types';\n// @ts-ignore\nimport type { Config, Settings } from 'meilisearch';\n\nexpor"
},
{
"path": "medusa/src/modules/resend/emails/auth-email-confirm.tsx",
"chars": 928,
"preview": "// External packages\nimport { Text, Heading, Button } from '@react-email/components';\n\n// Components\nimport EmailLayout,"
},
{
"path": "medusa/src/modules/resend/emails/auth-forgot-password.tsx",
"chars": 1662,
"preview": "// External components\nimport { Text, Heading, Button } from '@react-email/components';\n\n// Types\nimport { CustomerDTO }"
},
{
"path": "medusa/src/modules/resend/emails/auth-password-reset.tsx",
"chars": 1625,
"preview": "// External components\nimport { Text, Heading, Button } from '@react-email/components';\n\n// Types\nimport { CustomerDTO }"
},
{
"path": "medusa/src/modules/resend/emails/components/EmailLayout.tsx",
"chars": 5836,
"preview": "// External packages\nimport {\n Body,\n Column,\n Container,\n Font,\n Head,\n Hr,\n Html,\n Link,\n Row,\n Section,\n T"
},
{
"path": "medusa/src/modules/resend/emails/index.ts",
"chars": 703,
"preview": "import AuthPasswordForgotResetEmail from \"./auth-forgot-password\";\nimport AuthPasswordResetEmail from \"./auth-password-r"
},
{
"path": "medusa/src/modules/resend/emails/order-placed.tsx",
"chars": 11444,
"preview": "// External packages\nimport { Fragment } from 'react';\nimport {\n Text,\n Column,\n Heading,\n Img,\n Row,\n Section,\n "
},
{
"path": "medusa/src/modules/resend/emails/order-update.tsx",
"chars": 1686,
"preview": "// External packages\nimport { Text, Heading, Button } from '@react-email/components';\n\n// Types\nimport { CustomerDTO, Or"
},
{
"path": "medusa/src/modules/resend/emails/welcome.tsx",
"chars": 2680,
"preview": "// External packages\nimport { Text, Heading, Row, Column } from '@react-email/components';\nimport { CustomerDTO } from '"
},
{
"path": "medusa/src/modules/resend/index.ts",
"chars": 236,
"preview": "import { ModuleProvider, Modules } from '@medusajs/framework/utils';\nimport ResendNotificationProviderService from './se"
},
{
"path": "medusa/src/modules/resend/service.tsx",
"chars": 3059,
"preview": "import { AbstractNotificationProviderService } from '@medusajs/framework/utils';\nimport { Logger } from '@medusajs/medus"
},
{
"path": "medusa/src/scripts/README.md",
"chars": 1725,
"preview": "# Custom CLI Script\n\nA custom CLI script is a function to execute through Medusa's CLI tool. This is useful when creatin"
},
{
"path": "medusa/src/scripts/index-products.ts",
"chars": 868,
"preview": "import { ExecArgs, ISearchService } from '@medusajs/framework/types';\nimport { Modules } from '@medusajs/framework/utils"
},
{
"path": "medusa/src/scripts/seed.ts",
"chars": 78903,
"preview": "import {\n createApiKeysWorkflow,\n createCollectionsWorkflow,\n createProductCategoriesWorkflow,\n createProductsWorkfl"
},
{
"path": "medusa/src/subscribers/README.md",
"chars": 1751,
"preview": "# Custom subscribers\n\nSubscribers handle events emitted in the Medusa application.\n\nThe subscriber is created in a TypeS"
},
{
"path": "medusa/src/subscribers/auth-password-reset-notification.ts",
"chars": 1230,
"preview": "import type { SubscriberArgs, SubscriberConfig } from \"@medusajs/medusa\";\nimport { ContainerRegistrationKeys, Modules } "
},
{
"path": "medusa/src/subscribers/customer-welcome-notification.ts",
"chars": 1066,
"preview": "import type { SubscriberArgs, SubscriberConfig } from '@medusajs/medusa';\nimport { ContainerRegistrationKeys, Modules } "
},
{
"path": "medusa/src/subscribers/index-products.ts",
"chars": 1474,
"preview": "import type { SubscriberArgs, SubscriberConfig } from '@medusajs/medusa';\nimport { Modules } from '@medusajs/framework/u"
},
{
"path": "medusa/src/subscribers/order-placed-notification.ts",
"chars": 5389,
"preview": "import type { SubscriberArgs, SubscriberConfig } from '@medusajs/medusa';\nimport {\n ContainerRegistrationKeys,\n MathBN"
},
{
"path": "medusa/src/workflows/README.md",
"chars": 1423,
"preview": "# Custom Workflows\n\nA workflow is a series of queries and actions that complete a task.\n\nThe workflow is created in a Ty"
},
{
"path": "medusa/src/workflows/emit-customer-welcome-event.ts",
"chars": 484,
"preview": "import {\n createWorkflow,\n WorkflowResponse,\n} from '@medusajs/framework/workflows-sdk';\nimport { emitEventStep } from"
},
{
"path": "medusa/src/workflows/index-products.ts",
"chars": 1441,
"preview": "import {\n createStep,\n createWorkflow,\n StepResponse,\n WorkflowResponse,\n} from '@medusajs/framework/workflows-sdk';"
},
{
"path": "medusa/tsconfig.json",
"chars": 780,
"preview": "{\n \"compilerOptions\": {\n \"lib\": [\"es2021\"],\n \"target\": \"es2021\",\n \"allowJs\": true,\n \"esModuleInterop\": true"
},
{
"path": "storefront/.github/scripts/medusa-config.js",
"chars": 2360,
"preview": "const { defineConfig, loadEnv } = require(\"@medusajs/utils\")\n\nloadEnv(process.env.NODE_ENV || \"development\", process.cwd"
},
{
"path": "storefront/.github/workflows/test-e2e.yaml",
"chars": 4266,
"preview": "name: Medusa NextJS Template Tests\n\non:\n push:\n branches:\n - main\n pull_request:\n workflow_dispatch:\n\nenv:\n "
},
{
"path": "storefront/.gitignore",
"chars": 642,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# IDEs\n.idea\n.vscode\n\n# dependenc"
},
{
"path": "storefront/.prettierrc",
"chars": 137,
"preview": "{\n \"arrowParens\": \"always\",\n \"semi\": false,\n \"endOfLine\": \"auto\",\n \"singleQuote\": false,\n \"tabWidth\": 2,\n \"trailin"
},
{
"path": "storefront/.yarnrc.yml",
"chars": 25,
"preview": "nodeLinker: node-modules\n"
},
{
"path": "storefront/LICENSE",
"chars": 1063,
"preview": "MIT License\n\nCopyright (c) 2022 Medusa\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof "
},
{
"path": "storefront/README.md",
"chars": 10830,
"preview": "<p align=\"center\">\n <a href=\"https://www.medusajs.com\">\n <picture>\n <source media=\"(prefers-color-scheme: dark)\" sr"
},
{
"path": "storefront/check-env-variables.js",
"chars": 1754,
"preview": "const c = require(\"ansi-colors\")\n\nconst requiredEnvs = [\n {\n key: \"NEXT_PUBLIC_MEDUSA_BACKEND_URL\",\n description:"
},
{
"path": "storefront/e2e/README.md",
"chars": 3674,
"preview": "# About\n\nThis folder contains an end to end testing suite written with playwright checking all of the main functionality"
},
{
"path": "storefront/e2e/data/reset.ts",
"chars": 3208,
"preview": "import { Client } from \"pg\"\n\nasync function getDatabaseClient() {\n testEnvChecks()\n const env = getEnv()\n const clien"
},
{
"path": "storefront/e2e/data/seed.ts",
"chars": 2524,
"preview": "import axios, { AxiosError, AxiosInstance } from \"axios\"\n\naxios.defaults.baseURL = process.env.CLIENT_SERVER || \"http://"
},
{
"path": "storefront/e2e/fixtures/account/account-page.ts",
"chars": 1709,
"preview": "import { Locator, Page } from \"@playwright/test\"\nimport { BasePage } from \"../base/base-page\"\n\nexport class AccountPage "
},
{
"path": "storefront/e2e/fixtures/account/addresses-page.ts",
"chars": 1501,
"preview": "import { Locator, Page } from \"@playwright/test\"\nimport { AccountPage } from \"./account-page\"\nimport { AddressModal } fr"
},
{
"path": "storefront/e2e/fixtures/account/index.ts",
"chars": 1508,
"preview": "import { test as base } from \"@playwright/test\"\nimport { AddressesPage } from \"./addresses-page\"\nimport { LoginPage } fr"
},
{
"path": "storefront/e2e/fixtures/account/login-page.ts",
"chars": 845,
"preview": "import { Locator, Page } from \"@playwright/test\"\nimport { BasePage } from \"../base/base-page\"\n\nexport class LoginPage ex"
},
{
"path": "storefront/e2e/fixtures/account/modals/address-modal.ts",
"chars": 1485,
"preview": "import { Page, Locator } from \"@playwright/test\"\nimport { BaseModal } from \"../../base/base-modal\"\n\nexport class Address"
},
{
"path": "storefront/e2e/fixtures/account/order-page.ts",
"chars": 2895,
"preview": "import { Locator, Page } from \"@playwright/test\"\nimport { AccountPage } from \"./account-page\"\n\nexport class OrderPage ex"
},
{
"path": "storefront/e2e/fixtures/account/orders-page.ts",
"chars": 1807,
"preview": "import { Locator, Page } from \"@playwright/test\"\nimport { AccountPage } from \"./account-page\"\n\nexport class OrdersPage e"
},
{
"path": "storefront/e2e/fixtures/account/overview-page.ts",
"chars": 1642,
"preview": "import { Locator, Page } from \"@playwright/test\"\nimport { AccountPage } from \"./account-page\"\n\nexport class OverviewPage"
},
{
"path": "storefront/e2e/fixtures/account/profile-page.ts",
"chars": 6218,
"preview": "import { Locator, Page } from \"@playwright/test\"\nimport { AccountPage } from \"./account-page\"\nimport { camelCase } from "
},
{
"path": "storefront/e2e/fixtures/account/register-page.ts",
"chars": 1005,
"preview": "import { Locator, Page } from \"@playwright/test\"\nimport { BasePage } from \"../base/base-page\"\n\nexport class RegisterPage"
},
{
"path": "storefront/e2e/fixtures/base/base-modal.ts",
"chars": 495,
"preview": "import { Page, Locator } from \"@playwright/test\"\n\nexport class BaseModal {\n page: Page\n container: Locator\n closeButt"
},
{
"path": "storefront/e2e/fixtures/base/base-page.ts",
"chars": 1020,
"preview": "import { CartDropdown } from \"./cart-dropdown\"\nimport { NavMenu } from \"./nav-menu\"\nimport { Page, Locator } from \"@play"
},
{
"path": "storefront/e2e/fixtures/base/cart-dropdown.ts",
"chars": 1410,
"preview": "import { Locator, Page } from \"@playwright/test\"\n\nexport class CartDropdown {\n page: Page\n navCartLink: Locator\n cart"
},
{
"path": "storefront/e2e/fixtures/base/nav-menu.ts",
"chars": 1734,
"preview": "import { Locator, Page } from \"@playwright/test\"\n\nexport class NavMenu {\n page: Page\n navMenuButton: Locator\n navMenu"
},
{
"path": "storefront/e2e/fixtures/base/search-modal.ts",
"chars": 1210,
"preview": "import { Page, Locator } from \"@playwright/test\"\nimport { BaseModal } from \"./base-modal\"\nimport { NavMenu } from \"./nav"
},
{
"path": "storefront/e2e/fixtures/cart-page.ts",
"chars": 4144,
"preview": "import { Locator, Page } from \"@playwright/test\"\nimport { BasePage } from \"./base/base-page\"\n\nexport class CartPage exte"
},
{
"path": "storefront/e2e/fixtures/category-page.ts",
"chars": 1543,
"preview": "import { Locator, Page } from \"@playwright/test\"\nimport { BasePage } from \"./base/base-page\"\n\nexport class CategoryPage "
},
{
"path": "storefront/e2e/fixtures/checkout-page.ts",
"chars": 10035,
"preview": "import { ElementHandle, Locator, Page } from \"@playwright/test\"\nimport { BasePage } from \"./base/base-page\"\n\nexport clas"
},
{
"path": "storefront/e2e/fixtures/index.ts",
"chars": 1518,
"preview": "import { test as base, Page } from \"@playwright/test\"\nimport { resetDatabase } from \"../data/reset\"\nimport { CartPage } "
},
{
"path": "storefront/e2e/fixtures/modals/mobile-actions-modal.ts",
"chars": 560,
"preview": "import { Page, Locator } from \"@playwright/test\"\nimport { BaseModal } from \"../base/base-modal\"\n\nexport class MobileActi"
},
{
"path": "storefront/e2e/fixtures/order-page.ts",
"chars": 3350,
"preview": "import { Locator, Page } from \"@playwright/test\"\nimport { BasePage } from \"./base/base-page\"\n\nexport class OrderPage ext"
},
{
"path": "storefront/e2e/fixtures/product-page.ts",
"chars": 1844,
"preview": "import { Locator, Page } from \"@playwright/test\"\nimport { BasePage } from \"./base/base-page\"\nimport { MobileActionsModal"
},
{
"path": "storefront/e2e/fixtures/store-page.ts",
"chars": 483,
"preview": "import { Locator, Page } from \"@playwright/test\"\nimport { CategoryPage } from \"./category-page\"\n\nexport class StorePage "
},
{
"path": "storefront/e2e/index.ts",
"chars": 238,
"preview": "import { mergeTests } from \"@playwright/test\"\nimport { fixtures } from \"./fixtures\"\nimport { accountFixtures } from \"./f"
},
{
"path": "storefront/e2e/tests/authenticated/address.spec.ts",
"chars": 12750,
"preview": "import { AddressesPage } from \"../../fixtures/account/addresses-page\"\nimport { test, expect } from \"../../index\"\nimport "
},
{
"path": "storefront/e2e/tests/authenticated/orders.spec.ts",
"chars": 15930,
"preview": "import { test, expect } from \"../../index\"\n\ntest.describe(\"Account orders page tests\", async () => {\n test.beforeEach(a"
},
{
"path": "storefront/e2e/tests/authenticated/profile.spec.ts",
"chars": 10040,
"preview": "import { test, expect } from \"../../index\"\n\ntest.describe(\"Account profile tests\", () => {\n test(\"Profile completed upd"
},
{
"path": "storefront/e2e/tests/global/public-setup.ts",
"chars": 148,
"preview": "import { test as setup } from \"@playwright/test\"\nimport { seedData } from \"../../data/seed\"\n\nsetup(\"Seed data\", async ()"
},
{
"path": "storefront/e2e/tests/global/setup.ts",
"chars": 873,
"preview": "import { test as setup } from \"@playwright/test\"\nimport { seedData } from \"../../data/seed\"\nimport { OverviewPage as Acc"
},
{
"path": "storefront/e2e/tests/global/teardown.ts",
"chars": 246,
"preview": "import { test as teardown } from \"@playwright/test\"\nimport { dropTemplate, resetDatabase } from \"../../data/reset\"\n\ntear"
},
{
"path": "storefront/e2e/tests/public/cart.spec.ts",
"chars": 8810,
"preview": "/*\nTest List\n- login from the sign in page redirects you page to the cart\n*/\nimport { test, expect } from \"../../index\"\n"
},
{
"path": "storefront/e2e/tests/public/checkout.spec.ts",
"chars": 26957,
"preview": "import { test, expect } from \"../../index\"\nimport { compareFloats, getFloatValue } from \"../../utils\"\n\ntest.describe(\"Ch"
},
{
"path": "storefront/e2e/tests/public/discount.spec.ts",
"chars": 22607,
"preview": "import { seedDiscount, seedUser } from \"../../data/seed\"\nimport { test, expect } from \"../../index\"\n\ntest.describe(\"Disc"
},
{
"path": "storefront/e2e/tests/public/giftcard.spec.ts",
"chars": 33205,
"preview": "import { first } from \"lodash\"\nimport { seedGiftcard, seedUser } from \"../../data/seed\"\nimport { test, expect } from \".."
},
{
"path": "storefront/e2e/tests/public/login.spec.ts",
"chars": 2510,
"preview": "import { test, expect } from \"../../index\"\n\ntest.describe(\"Login Page functionality\", async () => {\n test(\"access login"
},
{
"path": "storefront/e2e/tests/public/register.spec.ts",
"chars": 2338,
"preview": "import { test, expect } from \"../../index\"\n\ntest.describe(\"User registration functionality\", async () => {\n test(\"regis"
},
{
"path": "storefront/e2e/tests/public/search.spec.ts",
"chars": 2467,
"preview": "import { test, expect } from \"../../index\"\n\ntest.describe(\"Search tests\", async () => {\n test(\"Searching for a specific"
},
{
"path": "storefront/e2e/utils/index.ts",
"chars": 284,
"preview": "export function getFloatValue(s: string) {\n return parseFloat(parseFloat(s).toFixed(2))\n}\n\nexport function compareFloat"
},
{
"path": "storefront/e2e/utils/locators.ts",
"chars": 397,
"preview": "import { Page, Locator} from '@playwright/test'\n\nexport async function getSelectedOptionText(page: Page, select: Locator"
},
{
"path": "storefront/eslint.config.cjs",
"chars": 1861,
"preview": "const { FlatCompat } = require(\"@eslint/eslintrc\")\n\nconst compat = new FlatCompat({\n baseDirectory: __dirname,\n})\n\nmodu"
},
{
"path": "storefront/next-env.d.ts",
"chars": 262,
"preview": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\n/// <reference path=\"./.next/types/rout"
},
{
"path": "storefront/next-sitemap.js",
"chars": 374,
"preview": "const excludedPaths = [\"/checkout\", \"/account/*\"]\n\nmodule.exports = {\n siteUrl: process.env.NEXT_PUBLIC_VERCEL_URL,\n g"
},
{
"path": "storefront/next.config.js",
"chars": 536,
"preview": "const checkEnvVariables = require(\"./check-env-variables\")\n\ncheckEnvVariables()\n\n/**\n * @type {import('next').NextConfig"
},
{
"path": "storefront/package.json",
"chars": 2048,
"preview": "{\n \"name\": \"fashion-starter\",\n \"version\": \"2.0.0\",\n \"private\": true,\n \"author\": \"Ante Primorac <ante@agilo.com>\",\n "
},
{
"path": "storefront/playwright.config.ts",
"chars": 2118,
"preview": "import { defineConfig, devices } from \"@playwright/test\"\nimport path from \"path\"\nimport \"dotenv/config.js\"\n\nexport const"
},
{
"path": "storefront/postcss.config.js",
"chars": 82,
"preview": "module.exports = {\n plugins: {\n tailwindcss: {},\n autoprefixer: {},\n },\n}\n"
},
{
"path": "storefront/src/app/[countryCode]/(checkout)/checkout/loading.tsx",
"chars": 404,
"preview": "import { Icon } from \"@/components/Icon\"\n\nexport default function Loading() {\n return (\n <div className=\"absolute le"
},
{
"path": "storefront/src/app/[countryCode]/(checkout)/checkout/page.tsx",
"chars": 664,
"preview": "import React from \"react\"\nimport { Metadata } from \"next\"\nimport { notFound } from \"next/navigation\"\nimport { getCartId "
},
{
"path": "storefront/src/app/[countryCode]/(checkout)/layout.tsx",
"chars": 2031,
"preview": "import * as React from \"react\"\nimport { Layout, LayoutColumn } from \"@/components/Layout\"\nimport { LocalizedLink } from "
},
{
"path": "storefront/src/app/[countryCode]/(checkout)/not-found.tsx",
"chars": 240,
"preview": "import { Metadata } from \"next\"\n\nimport NotFoundPage from \"app/not-found\"\n\nexport const metadata: Metadata = {\n title: "
},
{
"path": "storefront/src/app/[countryCode]/(main)/about/page.tsx",
"chars": 6891,
"preview": "import { Metadata } from \"next\"\nimport Image from \"next/image\"\nimport { StoreRegion } from \"@medusajs/types\"\nimport { li"
},
{
"path": "storefront/src/app/[countryCode]/(main)/account/layout.tsx",
"chars": 1302,
"preview": "import * as React from \"react\"\n\nimport { SignOutButton } from \"@modules/account/components/SignOutButton\"\nimport { Sideb"
},
{
"path": "storefront/src/app/[countryCode]/(main)/account/loading.tsx",
"chars": 157,
"preview": "import SkeletonAccountPage from \"@modules/skeletons/templates/skeleton-account-page\"\n\nexport default function Loading() "
},
{
"path": "storefront/src/app/[countryCode]/(main)/account/my-orders/[orderId]/page.tsx",
"chars": 9220,
"preview": "import * as React from \"react\"\nimport { Metadata } from \"next\"\nimport Image from \"next/image\"\nimport { HttpTypes } from "
},
{
"path": "storefront/src/app/[countryCode]/(main)/account/my-orders/page.tsx",
"chars": 5508,
"preview": "import * as React from \"react\"\nimport { Metadata } from \"next\"\nimport Image from \"next/image\"\nimport { HttpTypes } from "
},
{
"path": "storefront/src/app/[countryCode]/(main)/account/page.tsx",
"chars": 6627,
"preview": "import { Metadata } from \"next\"\nimport { redirect } from \"next/navigation\"\nimport { getCustomer } from \"@lib/data/custom"
},
{
"path": "storefront/src/app/[countryCode]/(main)/auth/forgot-password/page.tsx",
"chars": 772,
"preview": "import { Metadata } from \"next\"\nimport Image from \"next/image\"\nimport { ForgotPasswordForm } from \"@modules/auth/compone"
},
{
"path": "storefront/src/app/[countryCode]/(main)/auth/forgot-password/reset/page.tsx",
"chars": 929,
"preview": "import { Metadata } from \"next\"\nimport { notFound } from \"next/navigation\"\n\nimport { ChangePasswordForm } from \"@modules"
},
{
"path": "storefront/src/app/[countryCode]/(main)/auth/login/loading.tsx",
"chars": 1927,
"preview": "import Image from \"next/image\"\n\nimport { LocalizedLink } from \"@/components/LocalizedLink\"\nimport { Button } from \"@/com"
},
{
"path": "storefront/src/app/[countryCode]/(main)/auth/login/page.tsx",
"chars": 1640,
"preview": "import { Metadata } from \"next\"\nimport Image from \"next/image\"\nimport { redirect } from \"next/navigation\"\n\nimport { getC"
},
{
"path": "storefront/src/app/[countryCode]/(main)/auth/register/loading.tsx",
"chars": 2705,
"preview": "import Image from \"next/image\"\nimport { LocalizedLink } from \"@/components/LocalizedLink\"\nimport { Input } from \"@/compo"
},
{
"path": "storefront/src/app/[countryCode]/(main)/auth/register/page.tsx",
"chars": 1539,
"preview": "import { Metadata } from \"next\"\nimport Image from \"next/image\"\nimport { redirect } from \"next/navigation\"\n\nimport { getC"
},
{
"path": "storefront/src/app/[countryCode]/(main)/auth/reset-password/page.tsx",
"chars": 945,
"preview": "import { Metadata } from \"next\"\nimport { notFound } from \"next/navigation\"\n\nimport { ChangePasswordForm } from \"@modules"
},
{
"path": "storefront/src/app/[countryCode]/(main)/cart/loading.tsx",
"chars": 148,
"preview": "import SkeletonCartPage from \"@modules/skeletons/templates/skeleton-cart-page\"\n\nexport default function Loading() {\n re"
},
{
"path": "storefront/src/app/[countryCode]/(main)/cart/not-found.tsx",
"chars": 234,
"preview": "import { Metadata } from \"next\"\n\nimport NotFoundPage from \"app/not-found\"\n\nexport const metadata: Metadata = {\n title: "
},
{
"path": "storefront/src/app/[countryCode]/(main)/cart/page.tsx",
"chars": 236,
"preview": "import { Metadata } from \"next\"\nimport CartTemplate from \"@modules/cart/templates\"\n\nexport const metadata: Metadata = {\n"
},
{
"path": "storefront/src/app/[countryCode]/(main)/collections/[handle]/page.tsx",
"chars": 2708,
"preview": "import { Metadata } from \"next\"\nimport { notFound } from \"next/navigation\"\n\nimport {\n getCollectionByHandle,\n getColle"
},
{
"path": "storefront/src/app/[countryCode]/(main)/cookie-policy/page.tsx",
"chars": 4979,
"preview": "import { Metadata } from \"next\"\nimport { StoreRegion } from \"@medusajs/types\"\nimport { Layout, LayoutColumn } from \"@/co"
},
{
"path": "storefront/src/app/[countryCode]/(main)/inspiration/page.tsx",
"chars": 7267,
"preview": "import { Metadata } from \"next\"\nimport Image from \"next/image\"\nimport { StoreRegion } from \"@medusajs/types\"\nimport { li"
},
{
"path": "storefront/src/app/[countryCode]/(main)/layout.tsx",
"chars": 414,
"preview": "import { Metadata } from \"next\"\nimport { getBaseURL } from \"@lib/util/env\"\nimport { Header } from \"@/components/Header\"\n"
},
{
"path": "storefront/src/app/[countryCode]/(main)/not-found.tsx",
"chars": 234,
"preview": "import { Metadata } from \"next\"\n\nimport NotFoundPage from \"app/not-found\"\n\nexport const metadata: Metadata = {\n title: "
},
{
"path": "storefront/src/app/[countryCode]/(main)/order/confirmed/[id]/loading.tsx",
"chars": 166,
"preview": "import SkeletonOrderConfirmed from \"@modules/skeletons/templates/skeleton-order-confirmed\"\n\nexport default function Load"
},
{
"path": "storefront/src/app/[countryCode]/(main)/order/confirmed/[id]/page.tsx",
"chars": 613,
"preview": "import { Metadata } from \"next\"\nimport { notFound } from \"next/navigation\"\n\nimport OrderCompletedTemplate from \"@modules"
},
{
"path": "storefront/src/app/[countryCode]/(main)/page.tsx",
"chars": 4836,
"preview": "import { Metadata } from \"next\"\nimport Image from \"next/image\"\nimport { getRegion } from \"@lib/data/regions\"\nimport { ge"
},
{
"path": "storefront/src/app/[countryCode]/(main)/privacy-policy/page.tsx",
"chars": 6283,
"preview": "import { Metadata } from \"next\"\nimport { StoreRegion } from \"@medusajs/types\"\nimport { listRegions } from \"@lib/data/reg"
},
{
"path": "storefront/src/app/[countryCode]/(main)/products/[handle]/page.tsx",
"chars": 2442,
"preview": "import { Metadata } from \"next\"\nimport { notFound } from \"next/navigation\"\n\nimport { sdk } from \"@lib/config\"\nimport { g"
},
{
"path": "storefront/src/app/[countryCode]/(main)/search/page.tsx",
"chars": 1881,
"preview": "import { Metadata } from \"next\"\nimport { Layout, LayoutColumn } from \"@/components/Layout\"\nimport { Suspense } from \"rea"
},
{
"path": "storefront/src/app/[countryCode]/(main)/store/page.tsx",
"chars": 1154,
"preview": "import { Metadata } from \"next\"\n\nimport { SortOptions } from \"@modules/store/components/refinement-list/sort-products\"\ni"
},
{
"path": "storefront/src/app/[countryCode]/(main)/terms-of-use/page.tsx",
"chars": 6137,
"preview": "import { Metadata } from \"next\"\nimport { StoreRegion } from \"@medusajs/types\"\nimport { listRegions } from \"@lib/data/reg"
},
{
"path": "storefront/src/app/layout.tsx",
"chars": 895,
"preview": "import { Metadata } from \"next\"\nimport { SpeedInsights } from \"@vercel/speed-insights/next\"\nimport { Mona_Sans } from \"n"
},
{
"path": "storefront/src/app/not-found.tsx",
"chars": 1164,
"preview": "import { Metadata } from \"next\"\nimport { Layout, LayoutColumn } from \"@/components/Layout\"\nimport { LocalizedButtonLink "
},
{
"path": "storefront/src/app/robots.ts",
"chars": 333,
"preview": "import { MetadataRoute } from \"next\"\n\nexport default function robots(): MetadataRoute.Robots {\n if (process.env.DISALLO"
},
{
"path": "storefront/src/components/Button.tsx",
"chars": 5074,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport { twJoin, twMerge } from \"tailwind-merge\"\nimport * as ReactAria from"
},
{
"path": "storefront/src/components/Carousel.tsx",
"chars": 3510,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport { twJoin, twMerge } from \"tailwind-merge\"\nimport { EmblaCarouselType"
},
{
"path": "storefront/src/components/CartDrawer.tsx",
"chars": 4224,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport { HttpTypes } from \"@medusajs/types\"\nimport Item from \"@modules/cart"
},
{
"path": "storefront/src/components/CartIcon.tsx",
"chars": 595,
"preview": "import { Suspense } from \"react\"\nimport { getCartQuantity } from \"@lib/data/cart\"\nimport { Icon, IconProps } from \"@/com"
},
{
"path": "storefront/src/components/CollectionsSection.tsx",
"chars": 2195,
"preview": "import Image from \"next/image\"\nimport { getCollectionsList } from \"@lib/data/collections\"\nimport { Carousel } from \"@/co"
},
{
"path": "storefront/src/components/Dialog.tsx",
"chars": 1222,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ReactAria from \"react-aria-components\"\nimport { twMerge } from "
},
{
"path": "storefront/src/components/Drawer.tsx",
"chars": 1075,
"preview": "import * as React from \"react\"\nimport { twMerge } from \"tailwind-merge\"\nimport * as ReactAria from \"react-aria-component"
},
{
"path": "storefront/src/components/Footer.tsx",
"chars": 3675,
"preview": "\"use client\"\n\nimport { useParams, usePathname } from \"next/navigation\"\nimport { twMerge } from \"tailwind-merge\"\nimport {"
},
{
"path": "storefront/src/components/Forms.tsx",
"chars": 8194,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport { twJoin, twMerge } from \"tailwind-merge\"\nimport * as ReactAria from"
},
{
"path": "storefront/src/components/Header.tsx",
"chars": 2763,
"preview": "import * as React from \"react\"\nimport { listRegions } from \"@lib/data/regions\"\nimport { SearchField } from \"@/components"
},
{
"path": "storefront/src/components/HeaderDrawer.tsx",
"chars": 2818,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport { Button } from \"@/components/Button\"\nimport { Icon } from \"@/compon"
},
{
"path": "storefront/src/components/HeaderWrapper.tsx",
"chars": 2616,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport { usePathname } from \"next/navigation\"\nimport { useCountryCode } fro"
},
{
"path": "storefront/src/components/Icon.tsx",
"chars": 5542,
"preview": "import * as React from \"react\"\nimport { twJoin, twMerge } from \"tailwind-merge\"\nimport { ArrowLeft } from \"@/components/"
},
{
"path": "storefront/src/components/IconCircle.tsx",
"chars": 346,
"preview": "import * as React from \"react\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport const IconCircle: React.FC<React.Compone"
},
{
"path": "storefront/src/components/InputNumberField.tsx",
"chars": 6303,
"preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ReactAria from \"react-aria-components\"\nimport { twJoin, twMerge"
}
]
// ... and 179 more files (download for full content)
About this extraction
This page contains the full source code of the Agilo/fashion-starter GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 379 files (1002.1 KB), approximately 259.9k tokens, and a symbol index with 347 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.