initial source code
This commit is contained in:
commit
1c1eee3e7d
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.medusa
|
||||
.git
|
||||
.env*
|
||||
!.env.template
|
||||
static
|
||||
uploads
|
||||
coverage
|
||||
docs
|
||||
24
.env.template
Normal file
24
.env.template
Normal file
@ -0,0 +1,24 @@
|
||||
STORE_CORS=http://localhost:8000,https://docs.medusajs.com
|
||||
ADMIN_CORS=http://localhost:5173,http://localhost:9000,https://docs.medusajs.com
|
||||
AUTH_CORS=http://localhost:5173,http://localhost:9000,https://docs.medusajs.com
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=supersecret
|
||||
COOKIE_SECRET=supersecret
|
||||
DATABASE_URL=
|
||||
DB_NAME=medusa-backend
|
||||
|
||||
# Public URL of this backend. Uploaded images get this host in their URL, so
|
||||
# when exposing the backend through ngrok/a domain set it to that https URL.
|
||||
BACKEND_URL=http://localhost:9000
|
||||
|
||||
# Order confirmation emails (SMTP). Leave SMTP_HOST empty to disable.
|
||||
# Works with any provider (Resend, SendGrid, Mailgun, Gmail app password...).
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM="HOO Store <orders@example.com>"
|
||||
STORE_NAME=HOO Store
|
||||
# Storefront url shown in the email footer.
|
||||
STORE_URL=http://localhost:8000
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
/dist
|
||||
.DS_Store
|
||||
/uploads
|
||||
/node_modules
|
||||
yarn-error.log
|
||||
|
||||
.idea
|
||||
|
||||
coverage
|
||||
|
||||
!src/**
|
||||
|
||||
./tsconfig.tsbuildinfo
|
||||
medusa-db.sql
|
||||
build
|
||||
.cache
|
||||
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
.medusa
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.template
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@ -0,0 +1,19 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
# medusa build -> .medusa/server (backend + built admin)
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
PORT=8081
|
||||
COPY --from=builder /app/.medusa/server ./
|
||||
RUN npm install --omit=dev
|
||||
# file-local provider writes uploads to ./static (mount a volume here)
|
||||
RUN mkdir -p static
|
||||
EXPOSE 8081
|
||||
CMD ["npx", "medusa", "start"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Medusa Commerce
|
||||
|
||||
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.
|
||||
68
README.md
Normal file
68
README.md
Normal file
@ -0,0 +1,68 @@
|
||||
<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 >= 2 of `@medusajs/medusa`.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Visit the [Quickstart Guide](https://docs.medusajs.com/learn/installation) to set up a server.
|
||||
|
||||
Visit the [Docs](https://docs.medusajs.com/learn/installation#get-started) to learn more about our system requirements.
|
||||
|
||||
Account login admin
|
||||
Username : [bjnsunny@gmail.com]
|
||||
Password : [supersecretpassword]
|
||||
|
||||
|
||||
|
||||
## 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/learn/introduction/architecture) and [commerce modules](https://docs.medusajs.com/learn/fundamentals/modules/commerce-modules) in the Docs.
|
||||
|
||||
## 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/)
|
||||
50
docs/implementation-plan.md
Normal file
50
docs/implementation-plan.md
Normal file
@ -0,0 +1,50 @@
|
||||
# Backend Implementation Plan — Medusa (default, no core modification)
|
||||
|
||||
## Mục tiêu
|
||||
Khởi tạo Medusa backend chạy bằng cấu hình **mặc định** (không fork/sửa source Medusa), expose Store API cho storefront Next.js và Admin API cho quản trị viên.
|
||||
|
||||
## 1. Prerequisites
|
||||
- Node.js 20.x (đã có: v20.16.0)
|
||||
- PostgreSQL (dev: local hoặc Docker container)
|
||||
- Redis (dev: local hoặc Docker container; dùng cho event bus, cache, workflow engine)
|
||||
- Git (repo riêng biệt với frontend)
|
||||
|
||||
## 2. Scaffold dự án
|
||||
- Chạy `npx create-medusa-app@latest` (hoặc `medusa new`) trong thư mục `backend/`
|
||||
- Chọn: khởi tạo với PostgreSQL, không cần seed data mẫu mặc định của Medusa nếu không cần (sẽ seed data riêng của brand)
|
||||
- Kết quả: Medusa server + Admin dashboard (React, đi kèm sẵn, không cần build riêng)
|
||||
|
||||
## 3. Cấu hình môi trường (`.env`)
|
||||
- `DATABASE_URL` — kết nối PostgreSQL
|
||||
- `REDIS_URL` — kết nối Redis
|
||||
- `STORE_CORS`, `ADMIN_CORS`, `AUTH_CORS` — whitelist origin của frontend (Next.js dev: `http://localhost:3000`, prod: domain thật)
|
||||
- `JWT_SECRET`, `COOKIE_SECRET`
|
||||
|
||||
## 4. Cấu hình thương mại cơ bản (qua Admin, không cần code)
|
||||
- Tạo **Region** (ví dụ: Việt Nam — currency VND) và Sales Channel
|
||||
- Cấu hình **Shipping Option** cơ bản (giao hàng nội địa)
|
||||
- Cấu hình **Payment Provider** (bắt đầu với manual/COD cho MVP, hoặc Stripe nếu cần online payment ngay)
|
||||
- Tạo tài khoản Admin user đầu tiên
|
||||
|
||||
## 5. Product data model — sử dụng default, không custom module
|
||||
Theo quyết định đã thảo luận: **không cần sửa core Medusa** cho yêu cầu custom size đo (vòng eo, vai, ngực...):
|
||||
- Field nào cần nhập cho từng sản phẩm → lưu trong `product.metadata.measurement_fields` (JSON, sửa trực tiếp qua Admin có sẵn)
|
||||
- Giá trị khách nhập → lưu trong `line_item.metadata` khi add-to-cart (API `POST /store/carts/{id}/line-items` đã hỗ trợ sẵn tham số `metadata`)
|
||||
- Không cần custom module ở giai đoạn MVP. Cân nhắc custom module + Admin UI widget riêng ở giai đoạn sau nếu cần UI đẹp hơn hoặc query có cấu trúc theo số đo.
|
||||
|
||||
## 6. Tạo dữ liệu sản phẩm mẫu
|
||||
- Ít nhất 1-2 category, vài product với variant chuẩn (màu/size cố định) + 1 product minh họa dùng `measurement_fields` custom
|
||||
|
||||
## 7. Kiểm thử API
|
||||
- Verify Admin API (`/admin/*`) hoạt động qua Admin dashboard
|
||||
- Verify Store API (`/store/*`) trả đúng product/region/cart bằng publishable API key
|
||||
- Verify luồng: tạo cart → add line item với metadata custom size → checkout → order lưu đúng metadata
|
||||
|
||||
## 8. Git & Deploy target (ghi chú, chưa thực hiện)
|
||||
- Repo riêng biệt (`backend/` là git repo độc lập, không chung với `frontend/`)
|
||||
- Deploy: Railway hoặc Render (Node service) + Supabase/Neon (PostgreSQL managed) + Redis managed (Upstash hoặc addon của platform)
|
||||
|
||||
## Không nằm trong scope MVP này
|
||||
- Custom Medusa module cho measurement fields có cấu trúc riêng
|
||||
- Multi-currency / multi-region đầy đủ (chỉ 1 region VN cho MVP)
|
||||
- Payment gateway ngoài manual/Stripe cơ bản
|
||||
4
eslint.config.ts
Normal file
4
eslint.config.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { defineConfig } from "eslint/config"
|
||||
import medusa from "@medusajs/eslint-plugin"
|
||||
|
||||
export default defineConfig([...medusa.configs.recommended])
|
||||
24
instrumentation.ts
Normal file
24
instrumentation.ts
Normal file
@ -0,0 +1,24 @@
|
||||
// Uncomment this file to enable instrumentation and observability using OpenTelemetry
|
||||
// Refer to the docs for installation instructions: https://docs.medusajs.com/learn/debugging-and-testing/instrumentation
|
||||
|
||||
// import { registerOtel } from "@medusajs/medusa"
|
||||
// // If using an exporter other than Zipkin, require it here.
|
||||
// import { ZipkinExporter } from "@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,
|
||||
// query: true
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
27
jest.config.js
Normal file
27
jest.config.js
Normal file
@ -0,0 +1,27 @@
|
||||
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/", "<rootDir>/.medusa/"],
|
||||
setupFiles: ["./integration-tests/setup.js"],
|
||||
};
|
||||
|
||||
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"];
|
||||
}
|
||||
75
medusa-config.ts
Normal file
75
medusa-config.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { loadEnv, defineConfig } from '@medusajs/framework/utils'
|
||||
|
||||
loadEnv(process.env.NODE_ENV || 'development', process.cwd())
|
||||
|
||||
module.exports = defineConfig({
|
||||
projectConfig: {
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
databaseDriverOptions: {
|
||||
pool: { min: 0, max: 4 },
|
||||
},
|
||||
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,
|
||||
cookieSecret: process.env.COOKIE_SECRET,
|
||||
}
|
||||
},
|
||||
admin: {
|
||||
vite: () => ({
|
||||
server: {
|
||||
allowedHosts: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
modules: [
|
||||
{
|
||||
resolve: "./src/modules/newsletter",
|
||||
},
|
||||
{
|
||||
// Uploaded product/category images are served from /static. Set
|
||||
// BACKEND_URL to the PUBLIC url of this backend (e.g. the ngrok https
|
||||
// url) so image URLs saved in the DB are reachable from the storefront.
|
||||
resolve: "@medusajs/medusa/file",
|
||||
options: {
|
||||
providers: [
|
||||
{
|
||||
resolve: "@medusajs/medusa/file-local",
|
||||
id: "local",
|
||||
options: {
|
||||
backend_url: `${(process.env.BACKEND_URL || "http://localhost:9000").replace(/\/$/, "")}/static`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// Order confirmation emails (see src/subscribers/order-placed.ts). Only
|
||||
// registered once SMTP_HOST and SMTP_FROM are configured.
|
||||
...(process.env.SMTP_HOST && process.env.SMTP_FROM
|
||||
? [
|
||||
{
|
||||
resolve: "@medusajs/medusa/notification",
|
||||
options: {
|
||||
providers: [
|
||||
{
|
||||
resolve: "./src/modules/smtp-notification",
|
||||
id: "smtp",
|
||||
options: {
|
||||
channels: ["email"],
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT || 587),
|
||||
secure: process.env.SMTP_SECURE === "true",
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
from: process.env.SMTP_FROM,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
})
|
||||
20332
package-lock.json
generated
Normal file
20332
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
66
package.json
Normal file
66
package.json
Normal file
@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "hoo-store-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "HOO Store Medusa Backend Service",
|
||||
"author": "Medusa (https://medusajs.com)",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"sqlite",
|
||||
"postgres",
|
||||
"typescript",
|
||||
"ecommerce",
|
||||
"headless",
|
||||
"medusa"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "medusa build",
|
||||
"start": "medusa start",
|
||||
"dev": "medusa develop",
|
||||
"lint": "medusa lint",
|
||||
"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=false --runInBand --forceExit",
|
||||
"test:unit": "TEST_TYPE=unit NODE_OPTIONS=--experimental-vm-modules jest --silent --runInBand --forceExit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@medusajs/admin-sdk": "2.18.0",
|
||||
"@medusajs/admin-shared": "2.18.0",
|
||||
"@medusajs/caching": "2.18.0",
|
||||
"@medusajs/cli": "2.18.0",
|
||||
"@medusajs/dashboard": "2.18.0",
|
||||
"@medusajs/draft-order": "2.18.0",
|
||||
"@medusajs/framework": "2.18.0",
|
||||
"@medusajs/medusa": "2.18.0",
|
||||
"@medusajs/ui": "4.2.0",
|
||||
"@tanstack/react-query": "5.64.2",
|
||||
"nodemailer": "^6.9.16",
|
||||
"react-i18next": "13.5.0",
|
||||
"react-router-dom": "6.30.4",
|
||||
"zod": "4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@medusajs/eslint-plugin": "2.18.0",
|
||||
"@medusajs/test-utils": "2.18.0",
|
||||
"@swc/core": "^1.7.28",
|
||||
"@swc/jest": "^0.2.36",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/node": "^20.12.11",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/react": "^18.3.2",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"eslint": "^9.39.4",
|
||||
"jest": "^29.7.0",
|
||||
"jiti": "^2.7.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.14",
|
||||
"yalc": "^1.0.0-pre.53"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"packageManager": "npm@10.8.1"
|
||||
}
|
||||
33
src/admin/README.md
Normal file
33
src/admin/README.md
Normal file
@ -0,0 +1,33 @@
|
||||
# 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.
|
||||
|
||||
> Learn more about Admin Extensions in [this documentation](https://docs.medusajs.com/learn/fundamentals/admin).
|
||||
|
||||
## 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.
|
||||
58
src/admin/i18n/README.md
Normal file
58
src/admin/i18n/README.md
Normal file
@ -0,0 +1,58 @@
|
||||
# Admin Customizations Translations
|
||||
|
||||
The Medusa Admin dashboard supports multiple languages for its interface. Medusa uses [react-i18next](https://react.i18next.com/) to manage translations in the admin dashboard.
|
||||
|
||||
To add translations, create JSON translation files for each language under the `src/admin/i18n/json` directory. For example, create the `src/admin/i18n/json/en.json` file with the following content:
|
||||
|
||||
```json
|
||||
{
|
||||
"brands": {
|
||||
"title": "Brands",
|
||||
"description": "Manage your product brands"
|
||||
},
|
||||
"done": "Done"
|
||||
}
|
||||
```
|
||||
|
||||
Then, export the translations in `src/admin/i18n/index.ts`:
|
||||
|
||||
```ts
|
||||
import en from "./json/en.json" with { type: "json" }
|
||||
|
||||
export default {
|
||||
en: {
|
||||
translation: en,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Finally, use translations in your admin widgets and routes using the `useTranslation` hook:
|
||||
|
||||
```tsx
|
||||
import { defineWidgetConfig } from "@medusajs/admin-sdk"
|
||||
import { Button, Container, Heading } from "@medusajs/ui"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
const ProductWidget = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Container className="p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<Heading level="h2">{t("brands.title")}</Heading>
|
||||
<p>{t("brands.description")}</p>
|
||||
</div>
|
||||
<div className="flex justify-end px-6 py-4">
|
||||
<Button variant="primary">{t("done")}</Button>
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export const config = defineWidgetConfig({
|
||||
zone: "product.details.before",
|
||||
})
|
||||
|
||||
export default ProductWidget
|
||||
```
|
||||
|
||||
Learn more about translating admin extensions in the [Translate Admin Customizations](https://docs.medusajs.com/learn/fundamentals/admin/translations) documentation.
|
||||
1
src/admin/i18n/index.ts
Normal file
1
src/admin/i18n/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export default {}
|
||||
373
src/admin/routes/measurement-presets/page.tsx
Normal file
373
src/admin/routes/measurement-presets/page.tsx
Normal file
@ -0,0 +1,373 @@
|
||||
import { defineRouteConfig } from "@medusajs/admin-sdk"
|
||||
import { Adjustments, Trash, Pencil, Plus, Check } from "@medusajs/icons"
|
||||
import { Container, Heading, Text, Button, Input, Table, Badge, toast } from "@medusajs/ui"
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
export type MeasurementPreset = {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
const DEFAULT_PRESETS: MeasurementPreset[] = [
|
||||
{ id: "p_bust", name: "Bust", description: "Chest/bust circumference at fullest point" },
|
||||
{ id: "p_waist", name: "Waist", description: "Natural waist circumference" },
|
||||
{ id: "p_hips", name: "Hips", description: "Hip circumference at widest point" },
|
||||
{ id: "p_shoulder", name: "Shoulder Width", description: "Distance between shoulder points across the back" },
|
||||
{ id: "p_top_length", name: "Top Length", description: "Length from high shoulder point to hem" },
|
||||
{ id: "p_pant_length", name: "Pant Length", description: "Outseam length from waist to pant hem" },
|
||||
{ id: "p_thigh", name: "Thigh Circumference", description: "Widest part of upper thigh" },
|
||||
{ id: "p_height", name: "Height (cm)", description: "Total body height in centimeters" },
|
||||
{ id: "p_weight", name: "Weight (kg)", description: "Total body weight in kilograms" },
|
||||
]
|
||||
|
||||
const MeasurementPresetsPage = () => {
|
||||
const [store, setStore] = useState<any>(null)
|
||||
const [presets, setPresets] = useState<MeasurementPreset[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
// New preset form
|
||||
const [newName, setNewName] = useState("")
|
||||
const [newDescription, setNewDescription] = useState("")
|
||||
|
||||
// Editing state
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState("")
|
||||
const [editDescription, setEditDescription] = useState("")
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const res = await fetch("/admin/stores?fields=+metadata", { credentials: "include" })
|
||||
if (!res.ok) throw new Error("Failed to fetch store metadata")
|
||||
const data = await res.json()
|
||||
const currentStore = data.stores?.[0]
|
||||
setStore(currentStore)
|
||||
|
||||
if (currentStore?.metadata?.measurement_presets) {
|
||||
let raw = currentStore.metadata.measurement_presets
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
raw = JSON.parse(raw)
|
||||
} catch {}
|
||||
}
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
const normalized: MeasurementPreset[] = raw.map((item: any, idx: number) => {
|
||||
if (typeof item === "string") {
|
||||
return { id: `preset_${idx}`, name: item, description: "" }
|
||||
}
|
||||
return {
|
||||
id: item.id || `preset_${idx}_${Date.now()}`,
|
||||
name: item.name || "",
|
||||
description: item.description || "",
|
||||
}
|
||||
})
|
||||
setPresets(normalized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setPresets(DEFAULT_PRESETS)
|
||||
} catch (err: any) {
|
||||
toast.error("Error", { description: err.message || "Failed to load presets." })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
const handleAdd = () => {
|
||||
const trimmed = newName.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
if (presets.some((p) => p.name.toLowerCase() === trimmed.toLowerCase())) {
|
||||
toast.error("Duplicate field", { description: `A preset named "${trimmed}" already exists.` })
|
||||
return
|
||||
}
|
||||
|
||||
const newPreset: MeasurementPreset = {
|
||||
id: `p_${Date.now()}`,
|
||||
name: trimmed,
|
||||
description: newDescription.trim(),
|
||||
}
|
||||
|
||||
setPresets((prev) => [...prev, newPreset])
|
||||
setNewName("")
|
||||
setNewDescription("")
|
||||
toast.success("Added", { description: `"${trimmed}" added. Remember to click "Save Changes".` })
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setPresets((prev) => prev.filter((p) => p.id !== id))
|
||||
}
|
||||
|
||||
const startEdit = (preset: MeasurementPreset) => {
|
||||
setEditingId(preset.id)
|
||||
setEditName(preset.name)
|
||||
setEditDescription(preset.description || "")
|
||||
}
|
||||
|
||||
const saveEdit = () => {
|
||||
if (!editingId) return
|
||||
const trimmed = editName.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
setPresets((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === editingId
|
||||
? { ...p, name: trimmed, description: editDescription.trim() }
|
||||
: p
|
||||
)
|
||||
)
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const handleSaveToStore = async () => {
|
||||
if (!store?.id) {
|
||||
toast.error("Error", { description: "Store ID not available." })
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const response = await fetch(`/admin/stores/${store.id}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
...store.metadata,
|
||||
measurement_presets: presets,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save: ${response.statusText}`)
|
||||
}
|
||||
|
||||
toast.success("Success", {
|
||||
description: "Measurement preset fields updated successfully!",
|
||||
})
|
||||
} catch (err: any) {
|
||||
toast.error("Error", {
|
||||
description: err.message || "Failed to save configuration.",
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetDefaults = () => {
|
||||
setPresets(DEFAULT_PRESETS)
|
||||
toast.info("Reset", { description: "Reset to default preset fields. Click Save to persist." })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4 max-w-5xl mx-auto p-6">
|
||||
<Container className="p-0 divide-y">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div>
|
||||
<Heading level="h1" className="text-xl font-semibold">
|
||||
Measurement Presets Configuration
|
||||
</Heading>
|
||||
<Text className="text-ui-fg-subtle text-xs mt-1">
|
||||
Manage the reusable measurement fields (e.g. Bust, Waist, Length) that appear as quick-add options on product custom sizing widgets.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={handleResetDefaults}
|
||||
disabled={isSaving || isLoading}
|
||||
>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
onClick={handleSaveToStore}
|
||||
isLoading={isSaving}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add New Preset Form */}
|
||||
<div className="p-6 bg-ui-bg-subtle/50">
|
||||
<Text className="text-xs font-semibold text-ui-fg-base mb-3">
|
||||
Add New Measurement Field:
|
||||
</Text>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="Field Name (e.g. Sleeve Length, Neck...)"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleAdd()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="Description / Guide (optional)"
|
||||
value={newDescription}
|
||||
onChange={(e) => setNewDescription(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleAdd()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={handleAdd}
|
||||
disabled={!newName.trim()}
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
<Plus className="mr-1" /> Add Field
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Presets List Table */}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Text className="text-xs font-semibold text-ui-fg-base">
|
||||
Current Configured Fields
|
||||
</Text>
|
||||
<Badge color="blue" size="small">
|
||||
{presets.length} fields
|
||||
</Badge>
|
||||
</div>
|
||||
<Text className="text-[11px] text-ui-fg-muted">
|
||||
These fields will be available for quick selection in all product details.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="border border-ui-border-base rounded-md overflow-hidden">
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row className="bg-ui-bg-subtle">
|
||||
<Table.HeaderCell className="text-xs font-semibold py-2.5 w-1/4">Field Name</Table.HeaderCell>
|
||||
<Table.HeaderCell className="text-xs font-semibold py-2.5 w-1/2">Description / Measurement Guide</Table.HeaderCell>
|
||||
<Table.HeaderCell className="text-xs font-semibold py-2.5 text-right w-1/4">Actions</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{presets.length === 0 ? (
|
||||
<Table.Row>
|
||||
{/* Table.Cell's props type omits colSpan, so it is spread in */}
|
||||
<Table.Cell
|
||||
{...{ colSpan: 3 }}
|
||||
className="text-center py-6 text-ui-fg-muted text-xs"
|
||||
>
|
||||
No presets configured yet. Add fields above or click "Reset to Defaults".
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
) : (
|
||||
presets.map((preset) => {
|
||||
const isEditing = editingId === preset.id
|
||||
return (
|
||||
<Table.Row key={preset.id} className="hover:bg-ui-bg-subtle/30 transition-colors">
|
||||
<Table.Cell className="py-2.5">
|
||||
{isEditing ? (
|
||||
<Input
|
||||
size="small"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span className="font-semibold text-xs text-ui-fg-base">{preset.name}</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="py-2.5">
|
||||
{isEditing ? (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="Description..."
|
||||
value={editDescription}
|
||||
onChange={(e) => setEditDescription(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-ui-fg-muted">{preset.description || "—"}</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="py-2.5 text-right">
|
||||
<div className="flex items-center justify-end gap-x-1">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
onClick={saveEdit}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5 mr-1" /> Done
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={() => setEditingId(null)}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
variant="transparent"
|
||||
onClick={() => startEdit(preset)}
|
||||
className="h-7 px-2 text-ui-fg-muted hover:text-ui-fg-base"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="transparent"
|
||||
onClick={() => handleDelete(preset.id)}
|
||||
className="h-7 px-2 text-ui-fg-muted hover:text-ui-fg-error"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const config = defineRouteConfig({
|
||||
label: "Measurement Presets",
|
||||
icon: Adjustments,
|
||||
})
|
||||
|
||||
export default MeasurementPresetsPage
|
||||
125
src/admin/routes/newsletter/page.tsx
Normal file
125
src/admin/routes/newsletter/page.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { defineRouteConfig } from "@medusajs/admin-sdk"
|
||||
import { EnvelopeSolid } from "@medusajs/icons"
|
||||
import { Badge, Button, Container, Heading, Table, Text } from "@medusajs/ui"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
type Subscriber = {
|
||||
id: string
|
||||
email: string
|
||||
status: "subscribed" | "unsubscribed"
|
||||
source: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
const NewsletterPage = () => {
|
||||
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
|
||||
const [count, setCount] = useState(0)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true)
|
||||
fetch(
|
||||
`/admin/newsletter/subscribers?limit=${PAGE_SIZE}&offset=${offset}`,
|
||||
{ credentials: "include" }
|
||||
)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`)
|
||||
return res.json()
|
||||
})
|
||||
.then((data) => {
|
||||
setSubscribers(data.subscribers ?? [])
|
||||
setCount(data.count ?? 0)
|
||||
setError(null)
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setIsLoading(false))
|
||||
}, [offset])
|
||||
|
||||
const pageCount = Math.max(Math.ceil(count / PAGE_SIZE), 1)
|
||||
const page = Math.floor(offset / PAGE_SIZE) + 1
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div>
|
||||
<Heading>Newsletter</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
{count} subscriber{count === 1 ? "" : "s"} signed up from the
|
||||
storefront footer.
|
||||
</Text>
|
||||
</div>
|
||||
<Button variant="secondary" asChild>
|
||||
<a href="/admin/newsletter/subscribers?format=csv" download>
|
||||
Export CSV
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Text className="px-6 py-4 text-ui-fg-error">
|
||||
Could not load subscribers: {error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell>Email</Table.HeaderCell>
|
||||
<Table.HeaderCell>Status</Table.HeaderCell>
|
||||
<Table.HeaderCell>Source</Table.HeaderCell>
|
||||
<Table.HeaderCell>Date</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{!isLoading && subscribers.length === 0 && (
|
||||
<Table.Row>
|
||||
<Table.Cell>No subscribers yet.</Table.Cell>
|
||||
<Table.Cell />
|
||||
<Table.Cell />
|
||||
<Table.Cell />
|
||||
</Table.Row>
|
||||
)}
|
||||
{subscribers.map((s) => (
|
||||
<Table.Row key={s.id}>
|
||||
<Table.Cell>{s.email}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge
|
||||
size="2xsmall"
|
||||
color={s.status === "subscribed" ? "green" : "grey"}
|
||||
>
|
||||
{s.status}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{s.source ?? "-"}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{new Date(s.created_at).toLocaleString()}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
|
||||
<Table.Pagination
|
||||
count={count}
|
||||
pageSize={PAGE_SIZE}
|
||||
pageIndex={page - 1}
|
||||
pageCount={pageCount}
|
||||
canPreviousPage={offset > 0}
|
||||
canNextPage={offset + PAGE_SIZE < count}
|
||||
previousPage={() => setOffset(Math.max(offset - PAGE_SIZE, 0))}
|
||||
nextPage={() => setOffset(offset + PAGE_SIZE)}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export const config = defineRouteConfig({
|
||||
label: "Newsletter",
|
||||
icon: EnvelopeSolid,
|
||||
})
|
||||
|
||||
export default NewsletterPage
|
||||
27
src/admin/tsconfig.json
Normal file
27
src/admin/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"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": [
|
||||
".",
|
||||
"../../.medusa/types/plugin-augmentations.d.ts"
|
||||
]
|
||||
}
|
||||
1
src/admin/vite-env.d.ts
vendored
Normal file
1
src/admin/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
126
src/admin/widgets/category-image.tsx
Normal file
126
src/admin/widgets/category-image.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { defineWidgetConfig } from "@medusajs/admin-sdk"
|
||||
import { AdminProductCategory, DetailWidgetProps } from "@medusajs/types"
|
||||
import { Button, Container, Heading, Input, Text, toast } from "@medusajs/ui"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
/**
|
||||
* Lets the admin set the artwork shown for a category in the storefront
|
||||
* mega-menu. Medusa categories have no image field, so the URL is stored in
|
||||
* `category.metadata.image`, which the storefront reads.
|
||||
*/
|
||||
const CategoryImageWidget = ({
|
||||
data,
|
||||
}: DetailWidgetProps<AdminProductCategory>) => {
|
||||
const [image, setImage] = useState("")
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
const fileInput = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const current = data?.metadata?.image
|
||||
setImage(typeof current === "string" ? current : "")
|
||||
}, [data?.id, data?.metadata?.image])
|
||||
|
||||
const save = async (url: string) => {
|
||||
const res = await fetch(`/admin/product-categories/${data.id}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
metadata: { ...(data.metadata ?? {}), image: url || null },
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`Save failed (${res.status})`)
|
||||
}
|
||||
|
||||
const onUpload = async (file: File) => {
|
||||
setIsBusy(true)
|
||||
try {
|
||||
const form = new FormData()
|
||||
form.append("files", file)
|
||||
const res = await fetch("/admin/uploads", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: form,
|
||||
})
|
||||
if (!res.ok) throw new Error(`Upload failed (${res.status})`)
|
||||
const { files } = await res.json()
|
||||
const url: string | undefined = files?.[0]?.url
|
||||
if (!url) throw new Error("Upload returned no URL")
|
||||
await save(url)
|
||||
setImage(url)
|
||||
toast.success("Category image updated")
|
||||
} catch (err: any) {
|
||||
toast.error(err.message ?? "Could not upload image")
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
if (fileInput.current) fileInput.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const onSaveUrl = async () => {
|
||||
setIsBusy(true)
|
||||
try {
|
||||
await save(image.trim())
|
||||
toast.success("Category image saved")
|
||||
} catch (err: any) {
|
||||
toast.error(err.message ?? "Could not save image")
|
||||
} finally {
|
||||
setIsBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="flex flex-col gap-y-4">
|
||||
<div>
|
||||
<Heading level="h2">Category image</Heading>
|
||||
<Text size="small" className="text-ui-fg-subtle">
|
||||
Shown in the storefront mega-menu for this category (landscape works
|
||||
best, about 4:3). Upload a file or paste an image URL.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{image && (
|
||||
<img
|
||||
src={image}
|
||||
alt={data?.name}
|
||||
className="h-40 w-full rounded-md border border-ui-border-base object-cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex gap-x-2">
|
||||
<Input
|
||||
placeholder="https://..."
|
||||
value={image}
|
||||
onChange={(e) => setImage(e.target.value)}
|
||||
/>
|
||||
<Button variant="secondary" onClick={onSaveUrl} isLoading={isBusy}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInput}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) onUpload(file)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => fileInput.current?.click()}
|
||||
isLoading={isBusy}
|
||||
>
|
||||
Upload image
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export const config = defineWidgetConfig({
|
||||
zone: "product_category.details.after",
|
||||
})
|
||||
|
||||
export default CategoryImageWidget
|
||||
160
src/admin/widgets/order-measurements.tsx
Normal file
160
src/admin/widgets/order-measurements.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import { defineWidgetConfig } from "@medusajs/admin-sdk"
|
||||
import { DetailWidgetProps, AdminOrder } from "@medusajs/types"
|
||||
import { Container, Heading, Text, Badge, Table } from "@medusajs/ui"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
type MeasurementItem = {
|
||||
id: string
|
||||
title?: string
|
||||
subtitle?: string
|
||||
thumbnail?: string | null
|
||||
quantity?: number
|
||||
variant_sku?: string | null
|
||||
variant_title?: string | null
|
||||
metadata?: Record<string, any> | null
|
||||
}
|
||||
|
||||
const hasMeasurements = (item: any) => {
|
||||
const measurements = item?.metadata?.measurements
|
||||
return (
|
||||
measurements &&
|
||||
typeof measurements === "object" &&
|
||||
Object.keys(measurements).length > 0
|
||||
)
|
||||
}
|
||||
|
||||
const OrderMeasurementsWidget = ({ data }: DetailWidgetProps<AdminOrder>) => {
|
||||
// Items coming from the widget props, when the dashboard happens to include
|
||||
// the line item metadata in its own query.
|
||||
const itemsFromProps = ((data?.items || []) as any[]).filter(hasMeasurements)
|
||||
|
||||
const [items, setItems] = useState<MeasurementItem[]>(itemsFromProps)
|
||||
|
||||
useEffect(() => {
|
||||
setItems(itemsFromProps)
|
||||
|
||||
if (!data?.id) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
// The dashboard's order query does not guarantee `items.metadata`, so the
|
||||
// measurements are always resolved through our own admin route.
|
||||
fetch(`/admin/orders/${data.id}/measurements`, { credentials: "include" })
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((resData) => {
|
||||
if (cancelled || !resData?.items) {
|
||||
return
|
||||
}
|
||||
const fetched = (resData.items as MeasurementItem[]).filter(
|
||||
hasMeasurements
|
||||
)
|
||||
if (fetched.length > 0) {
|
||||
setItems(fetched)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [data?.id])
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-ui-bg-subtle">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Heading level="h2" className="text-base font-semibold text-ui-fg-base">
|
||||
Bespoke / Custom Sizing Measurements
|
||||
</Heading>
|
||||
<Badge color="blue" size="small">
|
||||
{items.length} custom sizing item(s)
|
||||
</Badge>
|
||||
</div>
|
||||
<Text className="text-xs text-ui-fg-muted">
|
||||
For tailors / fulfillment staff
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-y-6 px-6 py-5">
|
||||
{items.map((item) => {
|
||||
const measurements = item.metadata!.measurements as Record<
|
||||
string,
|
||||
string
|
||||
>
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="p-4 bg-ui-bg-base border border-ui-border-base rounded-md flex flex-col gap-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-x-3">
|
||||
{item.thumbnail && (
|
||||
<img
|
||||
src={item.thumbnail}
|
||||
alt={item.title}
|
||||
className="w-12 h-12 rounded object-cover border border-ui-border-base"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<Text className="font-semibold text-sm text-ui-fg-base">
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text className="text-xs text-ui-fg-subtle">
|
||||
Qty: {item.quantity}
|
||||
{item.variant_sku ? ` | SKU: ${item.variant_sku}` : ""}
|
||||
{item.variant_title
|
||||
? ` | Base variant: ${item.variant_title}`
|
||||
: ""}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Badge color="orange" size="small">
|
||||
Custom Size
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="border border-ui-border-base rounded overflow-hidden">
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Row className="bg-ui-bg-subtle">
|
||||
<Table.HeaderCell className="text-xs font-semibold text-ui-fg-subtle py-2">
|
||||
Measurement Field
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell className="text-xs font-semibold text-ui-fg-subtle py-2">
|
||||
Value
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{Object.entries(measurements).map(([key, value]) => (
|
||||
<Table.Row key={key}>
|
||||
<Table.Cell className="text-xs font-medium text-ui-fg-base py-2">
|
||||
{key}
|
||||
</Table.Cell>
|
||||
<Table.Cell className="text-xs font-semibold text-ui-fg-base py-2">
|
||||
{value || "—"}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export const config = defineWidgetConfig({
|
||||
zone: "order.details.before",
|
||||
})
|
||||
|
||||
export default OrderMeasurementsWidget
|
||||
415
src/admin/widgets/product-measurements.tsx
Normal file
415
src/admin/widgets/product-measurements.tsx
Normal file
@ -0,0 +1,415 @@
|
||||
import { defineWidgetConfig } from "@medusajs/admin-sdk"
|
||||
import { DetailWidgetProps, AdminProduct } from "@medusajs/types"
|
||||
import { Container, Heading, Text, Button, Input, Badge, toast } from "@medusajs/ui"
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
type PresetItem = {
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
type BespokeVariant = {
|
||||
id: string
|
||||
title?: string
|
||||
sku?: string | null
|
||||
}
|
||||
|
||||
const FALLBACK_PRESETS: PresetItem[] = [
|
||||
{ name: "Bust", description: "Chest/bust circumference" },
|
||||
{ name: "Waist", description: "Natural waist circumference" },
|
||||
{ name: "Hips", description: "Hip circumference" },
|
||||
{ name: "Shoulder Width", description: "Shoulder width across back" },
|
||||
{ name: "Top Length", description: "Length from shoulder to hem" },
|
||||
{ name: "Pant Length", description: "Outseam length" },
|
||||
{ name: "Thigh Circumference", description: "Upper thigh circumference" },
|
||||
{ name: "Height (cm)", description: "Total height in cm" },
|
||||
{ name: "Weight (kg)", description: "Total weight in kg" },
|
||||
]
|
||||
|
||||
const ProductMeasurementsWidget = ({ data }: DetailWidgetProps<AdminProduct>) => {
|
||||
const [fields, setFields] = useState<string[]>([])
|
||||
const [presetList, setPresetList] = useState<PresetItem[]>(FALLBACK_PRESETS)
|
||||
const [bespokePrice, setBespokePrice] = useState<string>("0")
|
||||
const [newField, setNewField] = useState("")
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isEnabled, setIsEnabled] = useState(false)
|
||||
const [bespokeStock, setBespokeStock] = useState<string>("2")
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [bespokeVariants, setBespokeVariants] = useState<BespokeVariant[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/admin/stores?fields=+metadata", { credentials: "include" })
|
||||
.then((res) => res.json())
|
||||
.then((resData) => {
|
||||
const store = resData.stores?.[0]
|
||||
if (store?.metadata?.measurement_presets) {
|
||||
let raw = store.metadata.measurement_presets
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
raw = JSON.parse(raw)
|
||||
} catch {}
|
||||
}
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
const list: PresetItem[] = raw
|
||||
.map((item: any) => ({
|
||||
name: typeof item === "string" ? item : (item.name || ""),
|
||||
description: typeof item === "string" ? "" : (item.description || ""),
|
||||
}))
|
||||
.filter((item) => Boolean(item.name))
|
||||
if (list.length > 0) {
|
||||
setPresetList(list)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.metadata?.measurement_fields) {
|
||||
const raw = data.metadata.measurement_fields
|
||||
let parsed: string[] = []
|
||||
if (Array.isArray(raw)) {
|
||||
parsed = raw as string[]
|
||||
} else if (typeof raw === "string") {
|
||||
try {
|
||||
const json = JSON.parse(raw)
|
||||
if (Array.isArray(json)) parsed = json
|
||||
else parsed = raw.split(",").map(s => s.trim()).filter(Boolean)
|
||||
} catch {
|
||||
parsed = raw.split(",").map(s => s.trim()).filter(Boolean)
|
||||
}
|
||||
}
|
||||
setFields(parsed)
|
||||
setIsEnabled(parsed.length > 0)
|
||||
} else {
|
||||
setFields([])
|
||||
setIsEnabled(false)
|
||||
}
|
||||
|
||||
if (data?.metadata?.bespoke_price !== undefined && data?.metadata?.bespoke_price !== null) {
|
||||
setBespokePrice(String(data.metadata.bespoke_price))
|
||||
} else {
|
||||
setBespokePrice("0")
|
||||
}
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
const existing = ((data?.variants || []) as any[])
|
||||
.filter((variant) => variant?.metadata?.is_bespoke === true)
|
||||
.map((variant) => ({
|
||||
id: variant.id,
|
||||
title: variant.title,
|
||||
sku: variant.sku,
|
||||
}))
|
||||
setBespokeVariants(existing)
|
||||
}, [data?.variants])
|
||||
|
||||
const handleGenerateBespokeVariants = async () => {
|
||||
setIsGenerating(true)
|
||||
try {
|
||||
const quantity = parseInt(bespokeStock, 10)
|
||||
|
||||
const response = await fetch(
|
||||
`/admin/products/${data.id}/bespoke-variants`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
stocked_quantity: isNaN(quantity) ? undefined : quantity,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result?.message || response.statusText)
|
||||
}
|
||||
|
||||
setBespokeVariants(result.variants || [])
|
||||
|
||||
toast.success("Bespoke variants ready", {
|
||||
description: `${result.created_count} created, ${
|
||||
(result.variants || []).length
|
||||
} total.${
|
||||
result.warnings?.length ? ` ${result.warnings.join(" ")}` : ""
|
||||
}`,
|
||||
})
|
||||
} catch (err: any) {
|
||||
toast.error("Error", {
|
||||
description: err.message || "Failed to generate bespoke variants.",
|
||||
})
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddField = (fieldToAdd: string) => {
|
||||
const trimmed = fieldToAdd.trim()
|
||||
if (!trimmed) return
|
||||
if (!fields.includes(trimmed)) {
|
||||
setFields(prev => [...prev, trimmed])
|
||||
setIsEnabled(true)
|
||||
}
|
||||
setNewField("")
|
||||
}
|
||||
|
||||
const handleRemoveField = (fieldToRemove: string) => {
|
||||
const next = fields.filter(f => f !== fieldToRemove)
|
||||
setFields(next)
|
||||
if (next.length === 0) {
|
||||
setIsEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const fieldsToSave = isEnabled ? fields : []
|
||||
const numericPrice = parseFloat(bespokePrice)
|
||||
const priceToSave = isNaN(numericPrice) || numericPrice < 0 ? 0 : numericPrice
|
||||
|
||||
const response = await fetch(`/admin/products/${data.id}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
...data.metadata,
|
||||
measurement_fields: fieldsToSave,
|
||||
bespoke_price: isEnabled ? priceToSave : null,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save: ${response.statusText}`)
|
||||
}
|
||||
|
||||
toast.success("Success", {
|
||||
description: "Custom measurements configuration updated successfully.",
|
||||
})
|
||||
} catch (err: any) {
|
||||
toast.error("Error", {
|
||||
description: err.message || "Failed to save configuration.",
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="divide-y p-0">
|
||||
<div className="flex items-center justify-between px-6 py-4">
|
||||
<div>
|
||||
<Heading level="h2" className="text-base font-semibold">
|
||||
Custom Sizing (Bespoke)
|
||||
</Heading>
|
||||
<Text className="text-ui-fg-subtle text-xs">
|
||||
Configure the measurement fields that customers will fill when choosing custom sizing for this product.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="small"
|
||||
variant={isEnabled ? "primary" : "secondary"}
|
||||
onClick={() => setIsEnabled(!isEnabled)}
|
||||
>
|
||||
{isEnabled ? "Enabled" : "Disabled"}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
isLoading={isSaving}
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEnabled ? (
|
||||
<div className="flex flex-col gap-y-4 px-6 py-4">
|
||||
<div>
|
||||
<Text className="text-ui-fg-muted mb-2 text-xs font-medium">
|
||||
Active measurement fields for this product:
|
||||
</Text>
|
||||
{fields.length === 0 ? (
|
||||
<Text className="text-ui-fg-subtle text-xs italic">
|
||||
No active fields yet. Choose from presets below or add new ones.
|
||||
</Text>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{fields.map((field) => (
|
||||
<Badge
|
||||
key={field}
|
||||
size="large"
|
||||
color="blue"
|
||||
className="flex items-center gap-x-1 pl-2.5 pr-1.5 py-1 text-xs"
|
||||
>
|
||||
<span>{field}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveField(field)}
|
||||
className="text-ui-fg-muted hover:text-ui-fg-base p-0.5 rounded focus:outline-none"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Text className="text-ui-fg-muted text-xs font-medium">
|
||||
Add preset fields:
|
||||
</Text>
|
||||
<a
|
||||
href="/app/measurement-presets"
|
||||
className="text-[11px] text-ui-fg-interactive hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
⚙ Manage presets
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{presetList.map((preset) => {
|
||||
const isSelected = fields.includes(preset.name)
|
||||
return (
|
||||
<button
|
||||
key={preset.name}
|
||||
type="button"
|
||||
disabled={isSelected}
|
||||
onClick={() => handleAddField(preset.name)}
|
||||
title={preset.description || preset.name}
|
||||
className={`text-xs px-2.5 py-1 rounded border transition-colors ${
|
||||
isSelected
|
||||
? "bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed opacity-50"
|
||||
: "bg-white text-gray-800 border-gray-300 hover:bg-gray-100 hover:text-black cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
+ {preset.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="text-ui-fg-muted mb-2 text-xs font-medium">
|
||||
Add other custom field:
|
||||
</Text>
|
||||
<div className="flex gap-x-2 max-w-md">
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="e.g. Sleeve Length, Neck..."
|
||||
value={newField}
|
||||
onChange={(e) => setNewField(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleAddField(newField)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={() => handleAddField(newField)}
|
||||
disabled={!newField.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-ui-border-base">
|
||||
<Text className="text-ui-fg-muted mb-1 text-xs font-medium">
|
||||
Bespoke / Custom Size Price:
|
||||
</Text>
|
||||
<div className="flex items-center gap-x-2 max-w-xs">
|
||||
<Input
|
||||
size="small"
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
placeholder="0"
|
||||
value={bespokePrice}
|
||||
onChange={(e) => setBespokePrice(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Text className="text-ui-fg-subtle text-[11px] mt-1">
|
||||
Set to 0 to allow free deposit / pay-later orders (admin will contact customer for payment). Or enter a fixed deposit amount.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-ui-border-base">
|
||||
<Text className="text-ui-fg-muted mb-1 text-xs font-medium">
|
||||
Bespoke variants:
|
||||
</Text>
|
||||
<Text className="text-ui-fg-subtle text-[11px] mb-2">
|
||||
Adds a "Bespoke" value to the size option and creates one
|
||||
variant per remaining option (e.g. per color). Orders then show
|
||||
"Bespoke" instead of a regular size, and the stock below
|
||||
limits how many bespoke orders can be placed.
|
||||
</Text>
|
||||
<div className="flex items-end gap-x-2 max-w-md">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<Text className="text-ui-fg-subtle text-[11px]">
|
||||
Bespoke slots (stock)
|
||||
</Text>
|
||||
<Input
|
||||
size="small"
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
placeholder="2"
|
||||
value={bespokeStock}
|
||||
onChange={(e) => setBespokeStock(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
onClick={handleGenerateBespokeVariants}
|
||||
isLoading={isGenerating}
|
||||
>
|
||||
Generate / sync bespoke variants
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{bespokeVariants.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{bespokeVariants.map((variant) => (
|
||||
<Badge key={variant.id} size="small" color="orange">
|
||||
{variant.title}
|
||||
{variant.sku ? ` · ${variant.sku}` : ""}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 py-4 bg-ui-bg-subtle">
|
||||
<Text className="text-ui-fg-muted text-xs">
|
||||
Custom sizing is disabled for this product. Click "Disabled" above to enable and configure fields.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export const config = defineWidgetConfig({
|
||||
zone: "product.details.after",
|
||||
})
|
||||
|
||||
export default ProductMeasurementsWidget
|
||||
135
src/api/README.md
Normal file
135
src/api/README.md
Normal file
@ -0,0 +1,135 @@
|
||||
# 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`.
|
||||
|
||||
> Learn more about API Routes in [this documentation](https://docs.medusajs.com/learn/fundamentals/api-routes)
|
||||
|
||||
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/framework/http";
|
||||
|
||||
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/framework/http";
|
||||
|
||||
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/framework/http"
|
||||
|
||||
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/framework/http"
|
||||
|
||||
export const GET = async (
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse
|
||||
) => {
|
||||
const productModuleService = req.scope.resolve("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/framework/http"
|
||||
import type {
|
||||
MedusaRequest,
|
||||
MedusaResponse,
|
||||
MedusaNextFunction,
|
||||
} from "@medusajs/framework/http";
|
||||
|
||||
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.
|
||||
8
src/api/admin/custom/route.ts
Normal file
8
src/api/admin/custom/route.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
||||
|
||||
export async function GET(
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse
|
||||
) {
|
||||
res.sendStatus(200);
|
||||
}
|
||||
49
src/api/admin/newsletter/subscribers/route.ts
Normal file
49
src/api/admin/newsletter/subscribers/route.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
|
||||
import { NEWSLETTER_MODULE } from "../../../../modules/newsletter"
|
||||
import NewsletterModuleService from "../../../../modules/newsletter/service"
|
||||
|
||||
const csvCell = (value: unknown) => {
|
||||
const text = value == null ? "" : String(value)
|
||||
// Neutralise spreadsheet formula injection, then quote.
|
||||
const safe = /^[=+\-@\t\r]/.test(text) ? `'${text}` : text
|
||||
return `"${safe.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists newsletter subscribers (newest first). `?format=csv` downloads every
|
||||
* subscriber as a CSV file instead.
|
||||
*/
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const service: NewsletterModuleService = req.scope.resolve(NEWSLETTER_MODULE)
|
||||
|
||||
if (req.query.format === "csv") {
|
||||
const subscribers = await service.listSubscribers(
|
||||
{},
|
||||
{ order: { created_at: "DESC" } }
|
||||
)
|
||||
const lines = [
|
||||
["email", "status", "source", "subscribed_at"].join(","),
|
||||
...subscribers.map((s) =>
|
||||
[s.email, s.status, s.source, s.created_at].map(csvCell).join(",")
|
||||
),
|
||||
]
|
||||
|
||||
res.setHeader("Content-Type", "text/csv; charset=utf-8")
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
'attachment; filename="newsletter-subscribers.csv"'
|
||||
)
|
||||
return res.send("" + lines.join("\r\n"))
|
||||
}
|
||||
|
||||
const limit = Math.min(Number(req.query.limit) || 50, 200)
|
||||
const offset = Math.max(Number(req.query.offset) || 0, 0)
|
||||
|
||||
const [subscribers, count] = await service.listAndCountSubscribers(
|
||||
{},
|
||||
{ take: limit, skip: offset, order: { created_at: "DESC" } }
|
||||
)
|
||||
|
||||
res.json({ subscribers, count, limit, offset })
|
||||
}
|
||||
69
src/api/admin/orders/[id]/measurements/route.ts
Normal file
69
src/api/admin/orders/[id]/measurements/route.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
import { ContainerRegistrationKeys, MedusaError } from "@medusajs/framework/utils"
|
||||
|
||||
/**
|
||||
* Returns the line items of an order together with their metadata.
|
||||
*
|
||||
* The admin dashboard fetches the order with its own field set, so we cannot
|
||||
* rely on `items.metadata` being present in the widget props. This route
|
||||
* always resolves the metadata explicitly.
|
||||
*/
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const { id: orderId } = req.params
|
||||
|
||||
if (!orderId) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Order ID is required in URL parameters"
|
||||
)
|
||||
}
|
||||
|
||||
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
||||
|
||||
const { data: orders } = await query.graph({
|
||||
entity: "order",
|
||||
filters: { id: orderId },
|
||||
fields: [
|
||||
"id",
|
||||
"display_id",
|
||||
"items.id",
|
||||
"items.title",
|
||||
"items.subtitle",
|
||||
"items.thumbnail",
|
||||
"items.quantity",
|
||||
// The quantity of an order line item lives on the order item ("detail"),
|
||||
// `items.quantity` alone comes back empty.
|
||||
"items.detail.quantity",
|
||||
"items.variant_sku",
|
||||
"items.variant_title",
|
||||
"items.unit_price",
|
||||
"items.metadata",
|
||||
],
|
||||
})
|
||||
|
||||
const order = orders?.[0]
|
||||
|
||||
if (!order) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Order with id '${orderId}' not found`
|
||||
)
|
||||
}
|
||||
|
||||
const items = (order.items || [])
|
||||
.filter((item: any) => {
|
||||
const measurements = item?.metadata?.measurements
|
||||
return (
|
||||
item?.metadata?.is_custom_size === true ||
|
||||
(measurements &&
|
||||
typeof measurements === "object" &&
|
||||
Object.keys(measurements).length > 0)
|
||||
)
|
||||
})
|
||||
.map((item: any) => ({
|
||||
...item,
|
||||
quantity: item.quantity ?? item.detail?.quantity ?? null,
|
||||
}))
|
||||
|
||||
res.status(200).json({ order_id: order.id, items })
|
||||
}
|
||||
353
src/api/admin/products/[id]/bespoke-variants/route.ts
Normal file
353
src/api/admin/products/[id]/bespoke-variants/route.ts
Normal file
@ -0,0 +1,353 @@
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
MedusaError,
|
||||
Modules,
|
||||
} from "@medusajs/framework/utils"
|
||||
import {
|
||||
createInventoryLevelsWorkflow,
|
||||
createProductVariantsWorkflow,
|
||||
setProductProductOptionsWorkflow,
|
||||
updateInventoryLevelsWorkflow,
|
||||
updateProductOptionsWorkflow,
|
||||
} from "@medusajs/core-flows"
|
||||
|
||||
const SIZE_OPTION_PATTERN = /size|kích|cỡ/i
|
||||
const DEFAULT_BESPOKE_VALUE = "Bespoke"
|
||||
|
||||
const PRODUCT_FIELDS = [
|
||||
"id",
|
||||
"title",
|
||||
"handle",
|
||||
"options.id",
|
||||
"options.title",
|
||||
"options.values.value",
|
||||
"variants.id",
|
||||
"variants.title",
|
||||
"variants.sku",
|
||||
"variants.metadata",
|
||||
"variants.options.option_id",
|
||||
"variants.options.value",
|
||||
"variants.prices.amount",
|
||||
"variants.prices.currency_code",
|
||||
"variants.inventory_items.inventory_item_id",
|
||||
]
|
||||
|
||||
type OptionValues = Record<string, string>
|
||||
|
||||
const slug = (value: string) =>
|
||||
value
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.toUpperCase()
|
||||
|
||||
/** Option values of a variant, keyed by option id. */
|
||||
const variantOptionMap = (variant: any): OptionValues =>
|
||||
(variant.options || []).reduce((acc: OptionValues, opt: any) => {
|
||||
acc[opt.option_id] = opt.value
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
/** Stable key for a combination of the non-size options. */
|
||||
const comboKey = (combo: OptionValues) =>
|
||||
Object.keys(combo)
|
||||
.sort()
|
||||
.map((k) => `${k}:${combo[k]}`)
|
||||
.join("|")
|
||||
|
||||
/** Cartesian product of the values of every option except the size option. */
|
||||
const buildCombos = (options: any[]): OptionValues[] => {
|
||||
return options.reduce(
|
||||
(combos: OptionValues[], option: any) => {
|
||||
const values: string[] = (option.values || []).map((v: any) => v.value)
|
||||
if (values.length === 0) {
|
||||
return combos
|
||||
}
|
||||
return combos.flatMap((combo) =>
|
||||
values.map((value) => ({ ...combo, [option.id]: value }))
|
||||
)
|
||||
},
|
||||
[{}] as OptionValues[]
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
||||
const { id: productId } = req.params
|
||||
const { value, stocked_quantity } = (req.body || {}) as {
|
||||
value?: string
|
||||
stocked_quantity?: number
|
||||
}
|
||||
|
||||
if (!productId) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Product ID is required in URL parameters"
|
||||
)
|
||||
}
|
||||
|
||||
const bespokeValue = (value || DEFAULT_BESPOKE_VALUE).trim()
|
||||
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
||||
|
||||
const { data: products } = await query.graph({
|
||||
entity: "product",
|
||||
filters: { id: productId },
|
||||
fields: PRODUCT_FIELDS,
|
||||
})
|
||||
|
||||
const product = products?.[0]
|
||||
|
||||
if (!product) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Product with id '${productId}' not found`
|
||||
)
|
||||
}
|
||||
|
||||
const options = product.options || []
|
||||
const sizeOption = options.find((o: any) =>
|
||||
SIZE_OPTION_PATTERN.test(o.title || "")
|
||||
)
|
||||
|
||||
if (!sizeOption) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
`Product '${product.title}' has no size option. Add an option named "Size" before generating bespoke variants.`
|
||||
)
|
||||
}
|
||||
|
||||
const warnings: string[] = []
|
||||
|
||||
// 1. Make sure the size option offers the bespoke value.
|
||||
const sizeValues: string[] = (sizeOption.values || []).map((v: any) => v.value)
|
||||
if (!sizeValues.includes(bespokeValue)) {
|
||||
// Existing values are passed back by id so the update never re-creates
|
||||
// them - variants and the product's option-value pivot reference those ids.
|
||||
await updateProductOptionsWorkflow(req.scope).run({
|
||||
input: {
|
||||
selector: { id: sizeOption.id },
|
||||
update: {
|
||||
values: [
|
||||
...(sizeOption.values || []).map((v: any) => ({
|
||||
id: v.id,
|
||||
value: v.value,
|
||||
})),
|
||||
{ value: bespokeValue },
|
||||
] as any,
|
||||
},
|
||||
},
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
// Creating the option value is not enough: a variant may only use values
|
||||
// that are registered on the product itself (the
|
||||
// product_product_option_value pivot), otherwise variant creation fails
|
||||
// with "Option value <x> does not exist for option <y>".
|
||||
const productModule: any = req.scope.resolve(Modules.PRODUCT)
|
||||
const optionValues = await productModule.listProductOptionValues(
|
||||
{ option_id: sizeOption.id },
|
||||
{ take: 200 }
|
||||
)
|
||||
const bespokeOptionValue = optionValues.find(
|
||||
(v: any) => v.value === bespokeValue
|
||||
)
|
||||
|
||||
if (!bespokeOptionValue) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.UNEXPECTED_STATE,
|
||||
`Could not create the "${bespokeValue}" value on option "${sizeOption.title}".`
|
||||
)
|
||||
}
|
||||
|
||||
await setProductProductOptionsWorkflow(req.scope).run({
|
||||
input: {
|
||||
product_id: product.id,
|
||||
update: [
|
||||
{
|
||||
product_option_id: sizeOption.id,
|
||||
add: [bespokeOptionValue.id],
|
||||
},
|
||||
],
|
||||
},
|
||||
throwOnError: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. One bespoke variant per combination of the remaining options (e.g. colors).
|
||||
const otherOptions = options.filter((o: any) => o.id !== sizeOption.id)
|
||||
const combos = buildCombos(otherOptions)
|
||||
|
||||
const variants = product.variants || []
|
||||
const existingBespoke = variants.filter(
|
||||
(v: any) => variantOptionMap(v)[sizeOption.id] === bespokeValue
|
||||
)
|
||||
const existingKeys = new Set(
|
||||
existingBespoke.map((v: any) => {
|
||||
const map = variantOptionMap(v)
|
||||
const combo: OptionValues = {}
|
||||
otherOptions.forEach((o: any) => (combo[o.id] = map[o.id]))
|
||||
return comboKey(combo)
|
||||
})
|
||||
)
|
||||
|
||||
const optionTitleById: Record<string, string> = {}
|
||||
options.forEach((o: any) => (optionTitleById[o.id] = o.title))
|
||||
|
||||
const skuBase = slug(product.handle || product.title || "product")
|
||||
|
||||
const toCreate = combos
|
||||
.filter((combo) => !existingKeys.has(comboKey(combo)))
|
||||
.map((combo) => {
|
||||
// A regular variant with the same non-size options, used as the source
|
||||
// for prices so the bespoke variant does not distort the product's
|
||||
// "from" price.
|
||||
const sibling = variants.find((v: any) => {
|
||||
const map = variantOptionMap(v)
|
||||
return (
|
||||
map[sizeOption.id] !== bespokeValue &&
|
||||
otherOptions.every((o: any) => map[o.id] === combo[o.id])
|
||||
)
|
||||
})
|
||||
|
||||
const prices = ((sibling as any)?.prices || []).map((p: any) => ({
|
||||
amount: p.amount,
|
||||
currency_code: p.currency_code,
|
||||
}))
|
||||
|
||||
if (prices.length === 0) {
|
||||
warnings.push(
|
||||
`No sibling price found for ${Object.values(combo).join(
|
||||
" / "
|
||||
)}; set a price on the bespoke variant manually.`
|
||||
)
|
||||
}
|
||||
|
||||
const comboValues = otherOptions.map((o: any) => combo[o.id])
|
||||
const skuSuffix = comboValues.length
|
||||
? `-${comboValues.map(slug).join("-")}`
|
||||
: ""
|
||||
|
||||
return {
|
||||
product_id: product.id,
|
||||
title: [bespokeValue, ...comboValues].join(" / "),
|
||||
sku: `${skuBase}-BESPOKE${skuSuffix}`,
|
||||
manage_inventory: true,
|
||||
allow_backorder: false,
|
||||
metadata: { is_bespoke: true },
|
||||
options: {
|
||||
[sizeOption.title]: bespokeValue,
|
||||
...comboValues.reduce((acc: Record<string, string>, val, idx) => {
|
||||
acc[otherOptions[idx].title] = val
|
||||
return acc
|
||||
}, {}),
|
||||
},
|
||||
prices,
|
||||
}
|
||||
})
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
await createProductVariantsWorkflow(req.scope).run({
|
||||
input: { product_variants: toCreate },
|
||||
throwOnError: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Re-read the product so newly created variants (and their inventory
|
||||
// items) are part of the result.
|
||||
const { data: refreshed } = await query.graph({
|
||||
entity: "product",
|
||||
filters: { id: productId },
|
||||
fields: PRODUCT_FIELDS,
|
||||
})
|
||||
|
||||
const bespokeVariants = (refreshed?.[0]?.variants || []).filter(
|
||||
(v: any) => variantOptionMap(v)[sizeOption.id] === bespokeValue
|
||||
)
|
||||
|
||||
// 4. Optionally set how many bespoke slots are available.
|
||||
if (stocked_quantity !== undefined && stocked_quantity !== null) {
|
||||
const quantity = Number(stocked_quantity)
|
||||
|
||||
if (isNaN(quantity) || quantity < 0) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"stocked_quantity must be a positive number"
|
||||
)
|
||||
}
|
||||
|
||||
const { data: locations } = await query.graph({
|
||||
entity: "stock_location",
|
||||
fields: ["id", "name"],
|
||||
})
|
||||
const location = locations?.[0]
|
||||
|
||||
if (!location) {
|
||||
warnings.push(
|
||||
"No stock location found, the bespoke quantity was not applied."
|
||||
)
|
||||
} else {
|
||||
const inventoryItemIds: string[] = bespokeVariants
|
||||
.flatMap((v: any) =>
|
||||
(v.inventory_items || []).map((i: any) => i.inventory_item_id)
|
||||
)
|
||||
.filter(Boolean)
|
||||
|
||||
const { data: levels } = await query.graph({
|
||||
entity: "inventory_level",
|
||||
filters: {
|
||||
inventory_item_id: inventoryItemIds,
|
||||
location_id: location.id,
|
||||
},
|
||||
fields: ["id", "inventory_item_id", "location_id"],
|
||||
})
|
||||
|
||||
const existingByItem = new Map(
|
||||
(levels || []).map((l: any) => [l.inventory_item_id, l])
|
||||
)
|
||||
|
||||
const toCreateLevels = inventoryItemIds
|
||||
.filter((itemId) => !existingByItem.has(itemId))
|
||||
.map((itemId) => ({
|
||||
inventory_item_id: itemId,
|
||||
location_id: location.id,
|
||||
stocked_quantity: quantity,
|
||||
}))
|
||||
|
||||
const toUpdateLevels = inventoryItemIds
|
||||
.filter((itemId) => existingByItem.has(itemId))
|
||||
.map((itemId) => ({
|
||||
id: (existingByItem.get(itemId) as any).id,
|
||||
inventory_item_id: itemId,
|
||||
location_id: location.id,
|
||||
stocked_quantity: quantity,
|
||||
}))
|
||||
|
||||
if (toCreateLevels.length > 0) {
|
||||
await createInventoryLevelsWorkflow(req.scope).run({
|
||||
input: { inventory_levels: toCreateLevels },
|
||||
throwOnError: true,
|
||||
})
|
||||
}
|
||||
|
||||
if (toUpdateLevels.length > 0) {
|
||||
await updateInventoryLevelsWorkflow(req.scope).run({
|
||||
input: { updates: toUpdateLevels },
|
||||
throwOnError: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
size_option: { id: sizeOption.id, title: sizeOption.title },
|
||||
bespoke_value: bespokeValue,
|
||||
created_count: toCreate.length,
|
||||
variants: bespokeVariants.map((v: any) => ({
|
||||
id: v.id,
|
||||
title: v.title,
|
||||
sku: v.sku,
|
||||
})),
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
243
src/api/store/carts/[id]/bespoke-line-item/route.ts
Normal file
243
src/api/store/carts/[id]/bespoke-line-item/route.ts
Normal file
@ -0,0 +1,243 @@
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
import { ContainerRegistrationKeys, remoteQueryObjectFromString, MedusaError } from "@medusajs/framework/utils"
|
||||
import { addToCartWorkflow } from "@medusajs/core-flows"
|
||||
|
||||
const MAX_DIGEST_FIELDS = 3
|
||||
|
||||
/**
|
||||
* Abbreviates a measurement label: "Height (cm)" -> "H", "Shoulder Width" -> "SW".
|
||||
*/
|
||||
const abbreviate = (label: string) =>
|
||||
label
|
||||
.replace(/\(.*?\)/g, "")
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((word) => word.charAt(0).toUpperCase())
|
||||
.join("") || label.trim()
|
||||
|
||||
/**
|
||||
* Short summary of the measurements, e.g. "H:170 · W:70 · SW:90".
|
||||
*
|
||||
* The admin order summary and the unfulfilled items list only render the line
|
||||
* item's title, sku and the live variant options, so the measurements have to
|
||||
* travel inside the title to be visible there.
|
||||
*/
|
||||
const buildMeasurementDigest = (metadata: Record<string, any>) => {
|
||||
const measurements = metadata?.measurements
|
||||
|
||||
if (!measurements || typeof measurements !== "object") {
|
||||
return ""
|
||||
}
|
||||
|
||||
const entries = Object.entries(measurements as Record<string, string>).filter(
|
||||
([, value]) => Boolean(String(value ?? "").trim())
|
||||
)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const shown = entries
|
||||
.slice(0, MAX_DIGEST_FIELDS)
|
||||
.map(([key, value]) => `${abbreviate(key)}:${String(value).trim()}`)
|
||||
.join(" · ")
|
||||
|
||||
return entries.length > MAX_DIGEST_FIELDS ? `${shown} …` : shown
|
||||
}
|
||||
|
||||
const isBespokeVariant = (variant: any) =>
|
||||
variant?.metadata?.is_bespoke === true
|
||||
|
||||
/**
|
||||
* Returns the product's dedicated bespoke variant (flagged with
|
||||
* `metadata.is_bespoke`) that best matches the options of the requested
|
||||
* variant. Falls back to the requested variant when the product has none.
|
||||
*/
|
||||
const resolveBespokeVariant = async (query: any, requestedVariant: any) => {
|
||||
if (isBespokeVariant(requestedVariant)) {
|
||||
return requestedVariant
|
||||
}
|
||||
|
||||
const productId = requestedVariant.product?.id
|
||||
|
||||
if (!productId) {
|
||||
return requestedVariant
|
||||
}
|
||||
|
||||
const { data: variants } = await query.graph({
|
||||
entity: "product_variant",
|
||||
filters: { product_id: productId },
|
||||
fields: [
|
||||
"id",
|
||||
"title",
|
||||
"sku",
|
||||
"metadata",
|
||||
"options.option_id",
|
||||
"options.value",
|
||||
"product.id",
|
||||
"product.title",
|
||||
"product.metadata",
|
||||
],
|
||||
})
|
||||
|
||||
const candidates = (variants || []).filter(isBespokeVariant)
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return requestedVariant
|
||||
}
|
||||
|
||||
const requestedOptions: Record<string, string> = (
|
||||
requestedVariant.options || []
|
||||
).reduce((acc: Record<string, string>, opt: any) => {
|
||||
acc[opt.option_id] = opt.value
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// The size option differs by design (e.g. "S" vs "Bespoke"), so pick the
|
||||
// candidate matching the most of the remaining options (e.g. the color).
|
||||
const scored = candidates.map((candidate: any) => {
|
||||
const score = (candidate.options || []).filter(
|
||||
(opt: any) => requestedOptions[opt.option_id] === opt.value
|
||||
).length
|
||||
return { candidate, score }
|
||||
})
|
||||
|
||||
scored.sort((a: any, b: any) => b.score - a.score)
|
||||
|
||||
return scored[0].candidate
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse
|
||||
) {
|
||||
const { id: cartId } = req.params
|
||||
const { variant_id, quantity = 1, metadata = {} } = (req.body || {}) as {
|
||||
variant_id: string
|
||||
quantity?: number
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
if (!cartId) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Cart ID is required in URL parameters"
|
||||
)
|
||||
}
|
||||
|
||||
if (!variant_id) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"variant_id is required in request body"
|
||||
)
|
||||
}
|
||||
|
||||
const remoteQuery = req.scope.resolve(ContainerRegistrationKeys.REMOTE_QUERY)
|
||||
const query = req.scope.resolve(ContainerRegistrationKeys.QUERY)
|
||||
|
||||
// 1. Fetch variant and its product to read bespoke_price from metadata
|
||||
const variantQuery = remoteQueryObjectFromString({
|
||||
entryPoint: "product_variant",
|
||||
variables: { filters: { id: variant_id } },
|
||||
fields: [
|
||||
"id",
|
||||
"title",
|
||||
"sku",
|
||||
"metadata",
|
||||
"options.option_id",
|
||||
"options.value",
|
||||
"product.id",
|
||||
"product.title",
|
||||
"product.metadata",
|
||||
],
|
||||
})
|
||||
|
||||
const [requestedVariant] = await remoteQuery(variantQuery)
|
||||
|
||||
if (!requestedVariant) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.NOT_FOUND,
|
||||
`Variant with id '${variant_id}' not found`
|
||||
)
|
||||
}
|
||||
|
||||
// 1b. If the storefront sent a regular variant while the product has a
|
||||
// dedicated bespoke variant, swap to it. That keeps the order rows readable
|
||||
// in the admin ("Bespoke · Black" instead of "S · Black") and makes the
|
||||
// stock come off the bespoke variant.
|
||||
const variant = await resolveBespokeVariant(query, requestedVariant)
|
||||
|
||||
// 2. Read bespoke price from product metadata (default to 0)
|
||||
const productMetadata = variant.product?.metadata as Record<string, any> | undefined
|
||||
let bespokePrice = 0
|
||||
if (productMetadata?.bespoke_price !== undefined && productMetadata?.bespoke_price !== null) {
|
||||
const parsed = Number(productMetadata.bespoke_price)
|
||||
if (!isNaN(parsed) && parsed >= 0) {
|
||||
bespokePrice = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Build line item snapshot labels so bespoke items are distinguishable
|
||||
// in the admin order summary (it only renders title, variant_sku and the
|
||||
// live variant options — never the line item metadata).
|
||||
const productTitle = (variant.product?.title as string | undefined) || "Item"
|
||||
const digest = buildMeasurementDigest(metadata)
|
||||
const bespokeTitle = digest
|
||||
? `${productTitle} — Bespoke (${digest})`
|
||||
: `${productTitle} — Bespoke`
|
||||
const bespokeSubtitle = variant.title
|
||||
? `${variant.title} · Custom size`
|
||||
: "Custom size"
|
||||
// A dedicated bespoke variant already carries a bespoke sku, only regular
|
||||
// variants need the suffix.
|
||||
const variantSku = variant.sku as string | undefined
|
||||
const bespokeSku = variantSku
|
||||
? variantSku.toUpperCase().includes("BESPOKE")
|
||||
? variantSku
|
||||
: `${variantSku}-BESPOKE`
|
||||
: "BESPOKE"
|
||||
|
||||
// 4. Execute addToCartWorkflow with custom unit_price
|
||||
await addToCartWorkflow(req.scope).run({
|
||||
input: {
|
||||
cart_id: cartId,
|
||||
items: [
|
||||
{
|
||||
variant_id: variant.id,
|
||||
quantity: Math.max(1, Number(quantity) || 1),
|
||||
unit_price: bespokePrice,
|
||||
title: bespokeTitle,
|
||||
subtitle: bespokeSubtitle,
|
||||
variant_sku: bespokeSku,
|
||||
metadata: {
|
||||
...metadata,
|
||||
is_custom_size: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
// 5. Refetch and return cart
|
||||
const cartQuery = remoteQueryObjectFromString({
|
||||
entryPoint: "cart",
|
||||
variables: { filters: { id: cartId } },
|
||||
fields: [
|
||||
"id",
|
||||
"currency_code",
|
||||
"email",
|
||||
"total",
|
||||
"subtotal",
|
||||
"items.*",
|
||||
"items.variant.*",
|
||||
"items.variant.product.*",
|
||||
"shipping_methods.*",
|
||||
"payment_collection.*",
|
||||
],
|
||||
})
|
||||
|
||||
const [cart] = await remoteQuery(cartQuery)
|
||||
|
||||
res.status(200).json({ cart })
|
||||
}
|
||||
8
src/api/store/custom/route.ts
Normal file
8
src/api/store/custom/route.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http";
|
||||
|
||||
export async function GET(
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse
|
||||
) {
|
||||
res.sendStatus(200);
|
||||
}
|
||||
50
src/api/store/newsletter/route.ts
Normal file
50
src/api/store/newsletter/route.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework/http"
|
||||
import { MedusaError } from "@medusajs/framework/utils"
|
||||
import { z } from "zod"
|
||||
|
||||
import { NEWSLETTER_MODULE } from "../../../modules/newsletter"
|
||||
import NewsletterModuleService from "../../../modules/newsletter/service"
|
||||
|
||||
const bodySchema = z.object({
|
||||
email: z.string().trim().toLowerCase().email().max(254),
|
||||
source: z.string().trim().max(64).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Public newsletter sign-up. Idempotent: subscribing an address twice (or
|
||||
* re-subscribing one that opted out) never creates a duplicate row.
|
||||
*/
|
||||
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
||||
const parsed = bodySchema.safeParse(req.body)
|
||||
|
||||
if (!parsed.success) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"A valid email address is required."
|
||||
)
|
||||
}
|
||||
|
||||
const { email, source } = parsed.data
|
||||
const service: NewsletterModuleService = req.scope.resolve(NEWSLETTER_MODULE)
|
||||
|
||||
const [existing] = await service.listSubscribers({ email }, { take: 1 })
|
||||
|
||||
if (existing) {
|
||||
if (existing.status === "unsubscribed") {
|
||||
await service.updateSubscribers({
|
||||
id: existing.id,
|
||||
status: "subscribed",
|
||||
})
|
||||
return res.status(200).json({ success: true, already_subscribed: false })
|
||||
}
|
||||
return res.status(200).json({ success: true, already_subscribed: true })
|
||||
}
|
||||
|
||||
await service.createSubscribers({
|
||||
email,
|
||||
source: source ?? null,
|
||||
status: "subscribed",
|
||||
})
|
||||
|
||||
return res.status(201).json({ success: true, already_subscribed: false })
|
||||
}
|
||||
38
src/jobs/README.md
Normal file
38
src/jobs/README.md
Normal file
@ -0,0 +1,38 @@
|
||||
# Custom scheduled jobs
|
||||
|
||||
A scheduled job is a function executed at a specified interval of time in the background of your Medusa application.
|
||||
|
||||
> Learn more about scheduled jobs in [this documentation](https://docs.medusajs.com/learn/fundamentals/scheduled-jobs).
|
||||
|
||||
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 {
|
||||
MedusaContainer
|
||||
} from "@medusajs/framework/types";
|
||||
|
||||
export default async function myCustomJob(container: MedusaContainer) {
|
||||
const productService = container.resolve("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.
|
||||
169
src/lib/order-email.ts
Normal file
169
src/lib/order-email.ts
Normal file
@ -0,0 +1,169 @@
|
||||
type Money = number | string | { value?: string | number } | null | undefined
|
||||
|
||||
export type OrderEmailOrder = {
|
||||
id: string
|
||||
display_id?: number | null
|
||||
email?: string | null
|
||||
currency_code: string
|
||||
created_at?: string | Date | null
|
||||
subtotal?: Money
|
||||
shipping_total?: Money
|
||||
discount_total?: Money
|
||||
tax_total?: Money
|
||||
total?: Money
|
||||
items?: Array<{
|
||||
title?: string | null
|
||||
subtitle?: string | null
|
||||
variant_sku?: string | null
|
||||
quantity: number
|
||||
unit_price?: Money
|
||||
thumbnail?: string | null
|
||||
}> | null
|
||||
shipping_address?: {
|
||||
first_name?: string | null
|
||||
last_name?: string | null
|
||||
address_1?: string | null
|
||||
address_2?: string | null
|
||||
city?: string | null
|
||||
province?: string | null
|
||||
postal_code?: string | null
|
||||
country_code?: string | null
|
||||
phone?: string | null
|
||||
} | null
|
||||
shipping_methods?: Array<{ name?: string | null }> | null
|
||||
}
|
||||
|
||||
const escape = (value: unknown) =>
|
||||
String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
|
||||
const toNumber = (value: Money): number => {
|
||||
if (value == null) return 0
|
||||
if (typeof value === "object") return Number(value.value ?? 0)
|
||||
return Number(value)
|
||||
}
|
||||
|
||||
export const formatMoney = (value: Money, currency: string) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency.toUpperCase(),
|
||||
}).format(toNumber(value))
|
||||
|
||||
/**
|
||||
* Builds the order-confirmation email (subject + HTML + plain text).
|
||||
* Table-based layout with inline styles so it renders in mail clients.
|
||||
*/
|
||||
export function buildOrderConfirmationEmail(
|
||||
order: OrderEmailOrder,
|
||||
storeName: string,
|
||||
storeUrl?: string
|
||||
) {
|
||||
const money = (v: Money) => formatMoney(v, order.currency_code)
|
||||
const orderNo = order.display_id ?? order.id
|
||||
const subject = `${storeName} - Order #${orderNo} confirmed`
|
||||
|
||||
const customerName = [
|
||||
order.shipping_address?.first_name,
|
||||
order.shipping_address?.last_name,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
|
||||
const itemRows = (order.items ?? [])
|
||||
.map((item) => {
|
||||
const detail = [item.subtitle, item.variant_sku && `SKU: ${item.variant_sku}`]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
return `<tr>
|
||||
<td style="padding:12px 0;border-bottom:1px solid #eee;font-size:14px;color:#111">
|
||||
${escape(item.title)}
|
||||
${detail ? `<div style="font-size:12px;color:#777;margin-top:2px">${escape(detail)}</div>` : ""}
|
||||
</td>
|
||||
<td align="center" style="padding:12px 8px;border-bottom:1px solid #eee;font-size:14px;color:#111">${item.quantity}</td>
|
||||
<td align="right" style="padding:12px 0;border-bottom:1px solid #eee;font-size:14px;color:#111">${money(toNumber(item.unit_price) * item.quantity)}</td>
|
||||
</tr>`
|
||||
})
|
||||
.join("")
|
||||
|
||||
const totalRow = (label: string, value: string, bold = false) =>
|
||||
`<tr>
|
||||
<td style="padding:4px 0;font-size:${bold ? 16 : 14}px;${bold ? "font-weight:bold;" : ""}color:#111">${label}</td>
|
||||
<td align="right" style="padding:4px 0;font-size:${bold ? 16 : 14}px;${bold ? "font-weight:bold;" : ""}color:#111">${value}</td>
|
||||
</tr>`
|
||||
|
||||
const a = order.shipping_address
|
||||
const addressLines = a
|
||||
? [
|
||||
customerName,
|
||||
a.address_1,
|
||||
a.address_2,
|
||||
[a.postal_code, a.city].filter(Boolean).join(" "),
|
||||
[a.province, a.country_code?.toUpperCase()].filter(Boolean).join(", "),
|
||||
a.phone,
|
||||
].filter(Boolean)
|
||||
: []
|
||||
|
||||
const shippingMethod = order.shipping_methods?.[0]?.name
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html><body style="margin:0;padding:0;background:#f5f4f2;font-family:Helvetica,Arial,sans-serif">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:24px 12px">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#fff;padding:32px">
|
||||
<tr><td style="text-align:center;font-size:20px;letter-spacing:4px;font-weight:bold;color:#111;padding-bottom:24px">${escape(storeName.toUpperCase())}</td></tr>
|
||||
<tr><td style="font-size:22px;color:#111;padding-bottom:8px">Thank you for your order${customerName ? `, ${escape(customerName)}` : ""}!</td></tr>
|
||||
<tr><td style="font-size:14px;color:#555;padding-bottom:24px">We have received your order <strong>#${escape(orderNo)}</strong> and will let you know when it ships.</td></tr>
|
||||
<tr><td>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<th align="left" style="font-size:12px;color:#777;text-transform:uppercase;padding-bottom:8px;border-bottom:1px solid #111">Item</th>
|
||||
<th align="center" style="font-size:12px;color:#777;text-transform:uppercase;padding-bottom:8px;border-bottom:1px solid #111">Qty</th>
|
||||
<th align="right" style="font-size:12px;color:#777;text-transform:uppercase;padding-bottom:8px;border-bottom:1px solid #111">Price</th>
|
||||
</tr>
|
||||
${itemRows}
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td style="padding-top:16px">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
|
||||
${totalRow("Subtotal", money(order.subtotal))}
|
||||
${toNumber(order.discount_total) ? totalRow("Discount", `-${money(order.discount_total)}`) : ""}
|
||||
${totalRow(`Shipping${shippingMethod ? ` (${escape(shippingMethod)})` : ""}`, money(order.shipping_total))}
|
||||
${toNumber(order.tax_total) ? totalRow("Tax", money(order.tax_total)) : ""}
|
||||
${totalRow("Total", money(order.total), true)}
|
||||
</table>
|
||||
</td></tr>
|
||||
${
|
||||
addressLines.length
|
||||
? `<tr><td style="padding-top:24px">
|
||||
<div style="font-size:12px;color:#777;text-transform:uppercase;padding-bottom:6px">Shipping address</div>
|
||||
<div style="font-size:14px;color:#111;line-height:1.5">${addressLines.map(escape).join("<br>")}</div>
|
||||
</td></tr>`
|
||||
: ""
|
||||
}
|
||||
<tr><td style="padding-top:32px;font-size:12px;color:#999;text-align:center">
|
||||
${storeUrl ? `<a href="${escape(storeUrl)}" style="color:#999">${escape(storeUrl)}</a><br>` : ""}
|
||||
© ${new Date().getFullYear()} ${escape(storeName)}
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr></table>
|
||||
</body></html>`
|
||||
|
||||
const text = [
|
||||
`Thank you for your order${customerName ? `, ${customerName}` : ""}!`,
|
||||
`Order #${orderNo}`,
|
||||
"",
|
||||
...(order.items ?? []).map(
|
||||
(i) => `${i.quantity} x ${i.title} - ${money(toNumber(i.unit_price) * i.quantity)}`
|
||||
),
|
||||
"",
|
||||
`Subtotal: ${money(order.subtotal)}`,
|
||||
`Shipping: ${money(order.shipping_total)}`,
|
||||
`Total: ${money(order.total)}`,
|
||||
"",
|
||||
...(addressLines.length ? ["Shipping address:", ...addressLines] : []),
|
||||
].join("\n")
|
||||
|
||||
return { subject, html, text }
|
||||
}
|
||||
26
src/links/README.md
Normal file
26
src/links/README.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Module Links
|
||||
|
||||
A module link forms an association between two data models of different modules, while maintaining module isolation.
|
||||
|
||||
> Learn more about links in [this documentation](https://docs.medusajs.com/learn/fundamentals/module-links)
|
||||
|
||||
For example:
|
||||
|
||||
```ts
|
||||
import BlogModule from "../modules/blog"
|
||||
import ProductModule from "@medusajs/medusa/product"
|
||||
import { defineLink } from "@medusajs/framework/utils"
|
||||
|
||||
export default defineLink(
|
||||
ProductModule.linkable.product,
|
||||
BlogModule.linkable.post
|
||||
)
|
||||
```
|
||||
|
||||
This defines a link between the Product Module's `product` data model and the Blog Module (custom module)'s `post` data model.
|
||||
|
||||
Then, in the Medusa application, run the following command to sync the links to the database:
|
||||
|
||||
```bash
|
||||
npx medusa db:migrate
|
||||
```
|
||||
839
src/migration-scripts/initial-data-seed.ts
Normal file
839
src/migration-scripts/initial-data-seed.ts
Normal file
@ -0,0 +1,839 @@
|
||||
import { MedusaContainer } from "@medusajs/framework";
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
ModuleRegistrationName,
|
||||
Modules,
|
||||
ProductStatus,
|
||||
} from "@medusajs/framework/utils";
|
||||
import {
|
||||
createApiKeysWorkflow,
|
||||
createCollectionsWorkflow,
|
||||
createInventoryLevelsWorkflow,
|
||||
createProductCategoriesWorkflow,
|
||||
createProductOptionsWorkflow,
|
||||
createProductsWorkflow,
|
||||
createRegionsWorkflow,
|
||||
createSalesChannelsWorkflow,
|
||||
createShippingOptionsWorkflow,
|
||||
createShippingProfilesWorkflow,
|
||||
createStockLocationsWorkflow,
|
||||
createStoresWorkflow,
|
||||
createTaxRegionsWorkflow,
|
||||
linkSalesChannelsToApiKeyWorkflow,
|
||||
linkSalesChannelsToStockLocationWorkflow,
|
||||
} from "@medusajs/medusa/core-flows";
|
||||
|
||||
export default async function initial_data_seed({
|
||||
container,
|
||||
}: {
|
||||
container: MedusaContainer;
|
||||
}) {
|
||||
const logger = container.resolve(ContainerRegistrationKeys.LOGGER);
|
||||
const link = container.resolve(ContainerRegistrationKeys.LINK);
|
||||
const query = container.resolve(ContainerRegistrationKeys.QUERY);
|
||||
const fulfillmentModuleService = container.resolve(
|
||||
ModuleRegistrationName.FULFILLMENT
|
||||
);
|
||||
|
||||
const countries = ["gb", "de", "dk", "se", "fr", "es", "it"];
|
||||
|
||||
logger.info("Seeding store data...");
|
||||
const {
|
||||
result: [defaultSalesChannel],
|
||||
} = await createSalesChannelsWorkflow(container).run({
|
||||
input: {
|
||||
salesChannelsData: [
|
||||
{
|
||||
name: "Default Sales Channel",
|
||||
description: "Created by Medusa",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
result: [publishableApiKey],
|
||||
} = await createApiKeysWorkflow(container).run({
|
||||
input: {
|
||||
api_keys: [
|
||||
{
|
||||
title: "Default Publishable API Key",
|
||||
type: "publishable",
|
||||
created_by: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await linkSalesChannelsToApiKeyWorkflow(container).run({
|
||||
input: {
|
||||
id: publishableApiKey.id,
|
||||
add: [defaultSalesChannel.id],
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
result: [store],
|
||||
} = await createStoresWorkflow(container).run({
|
||||
input: {
|
||||
stores: [
|
||||
{
|
||||
name: "HOO Store",
|
||||
supported_currencies: [
|
||||
{
|
||||
currency_code: "eur",
|
||||
is_default: true,
|
||||
},
|
||||
{
|
||||
currency_code: "usd",
|
||||
is_default: false,
|
||||
},
|
||||
],
|
||||
default_sales_channel_id: defaultSalesChannel.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Seeding region data...");
|
||||
const { result: regionResult } = await createRegionsWorkflow(container).run({
|
||||
input: {
|
||||
regions: [
|
||||
{
|
||||
name: "Europe",
|
||||
currency_code: "eur",
|
||||
countries,
|
||||
payment_providers: ["pp_system_default"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const region = regionResult[0];
|
||||
logger.info("Finished seeding regions.");
|
||||
|
||||
logger.info("Seeding tax regions...");
|
||||
await createTaxRegionsWorkflow(container).run({
|
||||
input: countries.map((country_code) => ({
|
||||
country_code,
|
||||
provider_id: "tp_system",
|
||||
})),
|
||||
});
|
||||
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 link.create({
|
||||
[Modules.STOCK_LOCATION]: {
|
||||
stock_location_id: stockLocation.id,
|
||||
},
|
||||
[Modules.FULFILLMENT]: {
|
||||
fulfillment_provider_id: "manual_manual",
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Seeding fulfillment data...");
|
||||
// This is created by a migration script in core.
|
||||
const { data: shippingProfileResult } = await query.graph({
|
||||
entity: "shipping_profile",
|
||||
fields: ["id"],
|
||||
});
|
||||
const shippingProfile = shippingProfileResult[0];
|
||||
|
||||
const fulfillmentSet = await fulfillmentModuleService.createFulfillmentSets({
|
||||
name: "European Warehouse delivery",
|
||||
type: "shipping",
|
||||
service_zones: [
|
||||
{
|
||||
name: "Europe",
|
||||
geo_zones: [
|
||||
{
|
||||
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 link.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",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
logger.info("Finished seeding fulfillment data.");
|
||||
|
||||
await linkSalesChannelsToStockLocationWorkflow(container).run({
|
||||
input: {
|
||||
id: stockLocation.id,
|
||||
add: [defaultSalesChannel.id],
|
||||
},
|
||||
});
|
||||
logger.info("Finished seeding stock location data.");
|
||||
|
||||
logger.info("Seeding product data...");
|
||||
|
||||
const { result: categoryResult } = await createProductCategoriesWorkflow(
|
||||
container
|
||||
).run({
|
||||
input: {
|
||||
product_categories: [
|
||||
{
|
||||
name: "Shirts",
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
name: "Sweatshirts",
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
name: "Pants",
|
||||
is_active: true,
|
||||
},
|
||||
{
|
||||
name: "Merch",
|
||||
is_active: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const { result: productOptionsResult } = await createProductOptionsWorkflow(
|
||||
container
|
||||
).run({
|
||||
input: {
|
||||
product_options: [
|
||||
{
|
||||
title: "Size",
|
||||
values: ["S", "M", "L", "XL"],
|
||||
},
|
||||
{
|
||||
title: "Color",
|
||||
values: ["Black", "White"],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const sizeOption = productOptionsResult.find((o) => o.title === "Size")!;
|
||||
const colorOption = productOptionsResult.find((o) => o.title === "Color")!;
|
||||
|
||||
await createProductsWorkflow(container).run({
|
||||
input: {
|
||||
products: [
|
||||
{
|
||||
title: "Medusa T-Shirt",
|
||||
category_ids: [
|
||||
categoryResult.find((cat) => cat.name === "Shirts")!.id,
|
||||
],
|
||||
description:
|
||||
"Reimagine the feeling of a classic T-shirt. With our cotton T-shirts, everyday essentials no longer have to be ordinary.",
|
||||
handle: "t-shirt",
|
||||
weight: 400,
|
||||
status: ProductStatus.PUBLISHED,
|
||||
shipping_profile_id: shippingProfile.id,
|
||||
images: [
|
||||
{
|
||||
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-black-front.png",
|
||||
},
|
||||
{
|
||||
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-black-back.png",
|
||||
},
|
||||
{
|
||||
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-white-front.png",
|
||||
},
|
||||
{
|
||||
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-white-back.png",
|
||||
},
|
||||
],
|
||||
options: [
|
||||
{ id: sizeOption.id },
|
||||
{ id: colorOption.id },
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "S / Black",
|
||||
sku: "SHIRT-S-BLACK",
|
||||
options: {
|
||||
Size: "S",
|
||||
Color: "Black",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "S / White",
|
||||
sku: "SHIRT-S-WHITE",
|
||||
options: {
|
||||
Size: "S",
|
||||
Color: "White",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "M / Black",
|
||||
sku: "SHIRT-M-BLACK",
|
||||
options: {
|
||||
Size: "M",
|
||||
Color: "Black",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "M / White",
|
||||
sku: "SHIRT-M-WHITE",
|
||||
options: {
|
||||
Size: "M",
|
||||
Color: "White",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "L / Black",
|
||||
sku: "SHIRT-L-BLACK",
|
||||
options: {
|
||||
Size: "L",
|
||||
Color: "Black",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "L / White",
|
||||
sku: "SHIRT-L-WHITE",
|
||||
options: {
|
||||
Size: "L",
|
||||
Color: "White",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "XL / Black",
|
||||
sku: "SHIRT-XL-BLACK",
|
||||
options: {
|
||||
Size: "XL",
|
||||
Color: "Black",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "XL / White",
|
||||
sku: "SHIRT-XL-WHITE",
|
||||
options: {
|
||||
Size: "XL",
|
||||
Color: "White",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sales_channels: [
|
||||
{
|
||||
id: defaultSalesChannel.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Medusa Sweatshirt",
|
||||
category_ids: [
|
||||
categoryResult.find((cat) => cat.name === "Sweatshirts")!.id,
|
||||
],
|
||||
description:
|
||||
"Reimagine the feeling of a classic sweatshirt. With our cotton sweatshirt, everyday essentials no longer have to be ordinary.",
|
||||
handle: "sweatshirt",
|
||||
weight: 400,
|
||||
status: ProductStatus.PUBLISHED,
|
||||
shipping_profile_id: shippingProfile.id,
|
||||
images: [
|
||||
{
|
||||
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatshirt-vintage-front.png",
|
||||
},
|
||||
{
|
||||
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatshirt-vintage-back.png",
|
||||
},
|
||||
],
|
||||
options: [{ id: sizeOption.id }],
|
||||
variants: [
|
||||
{
|
||||
title: "S",
|
||||
sku: "SWEATSHIRT-S",
|
||||
options: {
|
||||
Size: "S",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "M",
|
||||
sku: "SWEATSHIRT-M",
|
||||
options: {
|
||||
Size: "M",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "L",
|
||||
sku: "SWEATSHIRT-L",
|
||||
options: {
|
||||
Size: "L",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "XL",
|
||||
sku: "SWEATSHIRT-XL",
|
||||
options: {
|
||||
Size: "XL",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sales_channels: [
|
||||
{
|
||||
id: defaultSalesChannel.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Medusa Sweatpants",
|
||||
category_ids: [
|
||||
categoryResult.find((cat) => cat.name === "Pants")!.id,
|
||||
],
|
||||
description:
|
||||
"Reimagine the feeling of classic sweatpants. With our cotton sweatpants, everyday essentials no longer have to be ordinary.",
|
||||
handle: "sweatpants",
|
||||
weight: 400,
|
||||
status: ProductStatus.PUBLISHED,
|
||||
shipping_profile_id: shippingProfile.id,
|
||||
images: [
|
||||
{
|
||||
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png",
|
||||
},
|
||||
{
|
||||
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-back.png",
|
||||
},
|
||||
],
|
||||
options: [{ id: sizeOption.id }],
|
||||
variants: [
|
||||
{
|
||||
title: "S",
|
||||
sku: "SWEATPANTS-S",
|
||||
options: {
|
||||
Size: "S",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "M",
|
||||
sku: "SWEATPANTS-M",
|
||||
options: {
|
||||
Size: "M",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "L",
|
||||
sku: "SWEATPANTS-L",
|
||||
options: {
|
||||
Size: "L",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "XL",
|
||||
sku: "SWEATPANTS-XL",
|
||||
options: {
|
||||
Size: "XL",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sales_channels: [
|
||||
{
|
||||
id: defaultSalesChannel.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Medusa Shorts",
|
||||
category_ids: [
|
||||
categoryResult.find((cat) => cat.name === "Merch")!.id,
|
||||
],
|
||||
description:
|
||||
"Reimagine the feeling of classic shorts. With our cotton shorts, everyday essentials no longer have to be ordinary.",
|
||||
handle: "shorts",
|
||||
weight: 400,
|
||||
status: ProductStatus.PUBLISHED,
|
||||
shipping_profile_id: shippingProfile.id,
|
||||
images: [
|
||||
{
|
||||
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/shorts-vintage-front.png",
|
||||
},
|
||||
{
|
||||
url: "https://medusa-public-images.s3.eu-west-1.amazonaws.com/shorts-vintage-back.png",
|
||||
},
|
||||
],
|
||||
options: [{ id: sizeOption.id }],
|
||||
variants: [
|
||||
{
|
||||
title: "S",
|
||||
sku: "SHORTS-S",
|
||||
options: {
|
||||
Size: "S",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "M",
|
||||
sku: "SHORTS-M",
|
||||
options: {
|
||||
Size: "M",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "L",
|
||||
sku: "SHORTS-L",
|
||||
options: {
|
||||
Size: "L",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "XL",
|
||||
sku: "SHORTS-XL",
|
||||
options: {
|
||||
Size: "XL",
|
||||
},
|
||||
prices: [
|
||||
{
|
||||
amount: 10,
|
||||
currency_code: "eur",
|
||||
},
|
||||
{
|
||||
amount: 15,
|
||||
currency_code: "usd",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
sales_channels: [
|
||||
{
|
||||
id: defaultSalesChannel.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
logger.info("Finished seeding product data.");
|
||||
|
||||
logger.info("Seeding inventory levels.");
|
||||
|
||||
const { data: inventoryItems } = await query.graph({
|
||||
entity: "inventory_item",
|
||||
fields: ["id"],
|
||||
});
|
||||
|
||||
await createInventoryLevelsWorkflow(container).run({
|
||||
input: {
|
||||
inventory_levels: inventoryItems.map((item) => ({
|
||||
location_id: stockLocation.id,
|
||||
stocked_quantity: 1000000,
|
||||
inventory_item_id: item.id,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Finished seeding inventory levels data.");
|
||||
}
|
||||
117
src/modules/README.md
Normal file
117
src/modules/README.md
Normal file
@ -0,0 +1,117 @@
|
||||
# Custom Module
|
||||
|
||||
A module is a package of reusable functionalities. It can be integrated into your Medusa application without affecting the overall system. You can create a module as part of a plugin.
|
||||
|
||||
> Learn more about modules in [this documentation](https://docs.medusajs.com/learn/fundamentals/modules).
|
||||
|
||||
To create a module:
|
||||
|
||||
## 1. Create a Data Model
|
||||
|
||||
A data model represents a table in the database. You create a data model in a TypeScript or JavaScript file under the `models` directory of a module.
|
||||
|
||||
For example, create the file `src/modules/blog/models/post.ts` with the following content:
|
||||
|
||||
```ts
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
|
||||
const Post = model.define("post", {
|
||||
id: model.id().primaryKey(),
|
||||
title: model.text(),
|
||||
})
|
||||
|
||||
export default Post
|
||||
```
|
||||
|
||||
## 2. 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/blog/service.ts` with the following content:
|
||||
|
||||
```ts
|
||||
import { MedusaService } from "@medusajs/framework/utils"
|
||||
import Post from "./models/post"
|
||||
|
||||
class BlogModuleService extends MedusaService({
|
||||
Post,
|
||||
}){
|
||||
}
|
||||
|
||||
export default BlogModuleService
|
||||
```
|
||||
|
||||
## 3. 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/blog/index.ts` with the following content:
|
||||
|
||||
```ts
|
||||
import BlogModuleService from "./service"
|
||||
import { Module } from "@medusajs/framework/utils"
|
||||
|
||||
export const BLOG_MODULE = "blog"
|
||||
|
||||
export default Module(BLOG_MODULE, {
|
||||
service: BlogModuleService,
|
||||
})
|
||||
```
|
||||
|
||||
## 4. Add Module to Medusa's Configurations
|
||||
|
||||
To start using the module, add it to `medusa-config.ts`:
|
||||
|
||||
```ts
|
||||
module.exports = defineConfig({
|
||||
projectConfig: {
|
||||
// ...
|
||||
},
|
||||
modules: [
|
||||
{
|
||||
resolve: "./src/modules/blog",
|
||||
},
|
||||
],
|
||||
})
|
||||
```
|
||||
|
||||
## 5. Generate and Run Migrations
|
||||
|
||||
To generate migrations for your module, run the following command:
|
||||
|
||||
```bash
|
||||
npx medusa db:generate blog
|
||||
```
|
||||
|
||||
Then, to run migrations, run the following command:
|
||||
|
||||
```bash
|
||||
npx medusa db:migrate
|
||||
```
|
||||
|
||||
## Use Module
|
||||
|
||||
You can use the module in customizations within the Medusa application, such as workflows and API routes.
|
||||
|
||||
For example, to use the module in an API route:
|
||||
|
||||
```ts
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/framework"
|
||||
import BlogModuleService from "../../../modules/blog/service"
|
||||
import { BLOG_MODULE } from "../../../modules/blog"
|
||||
|
||||
export async function GET(
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse
|
||||
): Promise<void> {
|
||||
const blogModuleService: BlogModuleService = req.scope.resolve(
|
||||
BLOG_MODULE
|
||||
)
|
||||
|
||||
const posts = await blogModuleService.listPosts()
|
||||
|
||||
res.json({
|
||||
posts
|
||||
})
|
||||
}
|
||||
```
|
||||
9
src/modules/newsletter/index.ts
Normal file
9
src/modules/newsletter/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from "@medusajs/framework/utils"
|
||||
|
||||
import NewsletterModuleService from "./service"
|
||||
|
||||
export const NEWSLETTER_MODULE = "newsletter"
|
||||
|
||||
export default Module(NEWSLETTER_MODULE, {
|
||||
service: NewsletterModuleService,
|
||||
})
|
||||
163
src/modules/newsletter/migrations/.snapshot-newsletter.json
Normal file
163
src/modules/newsletter/migrations/.snapshot-newsletter.json
Normal file
@ -0,0 +1,163 @@
|
||||
{
|
||||
"namespaces": [
|
||||
"public"
|
||||
],
|
||||
"name": "public",
|
||||
"tables": [
|
||||
{
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": true,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"length": null,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": null,
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "text"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"length": null,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": null,
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "text"
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"unique": false,
|
||||
"length": null,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": null,
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "text"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"length": null,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": "'subscribed'",
|
||||
"comment": null,
|
||||
"enumItems": [
|
||||
"subscribed",
|
||||
"unsubscribed"
|
||||
],
|
||||
"mappedType": "enum"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"length": 6,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": "now()",
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": false,
|
||||
"unique": false,
|
||||
"length": 6,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": "now()",
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "datetime"
|
||||
},
|
||||
"deleted_at": {
|
||||
"name": "deleted_at",
|
||||
"type": "timestamptz",
|
||||
"unsigned": false,
|
||||
"autoincrement": false,
|
||||
"primary": false,
|
||||
"nullable": true,
|
||||
"unique": false,
|
||||
"length": 6,
|
||||
"precision": null,
|
||||
"scale": null,
|
||||
"default": null,
|
||||
"comment": null,
|
||||
"enumItems": [],
|
||||
"mappedType": "datetime"
|
||||
}
|
||||
},
|
||||
"name": "newsletter_subscriber",
|
||||
"schema": "public",
|
||||
"indexes": [
|
||||
{
|
||||
"keyName": "IDX_newsletter_subscriber_email_unique",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE UNIQUE INDEX IF NOT EXISTS \"IDX_newsletter_subscriber_email_unique\" ON \"newsletter_subscriber\" (\"email\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "IDX_newsletter_subscriber_deleted_at",
|
||||
"columnNames": [],
|
||||
"composite": false,
|
||||
"constraint": false,
|
||||
"primary": false,
|
||||
"unique": false,
|
||||
"expression": "CREATE INDEX IF NOT EXISTS \"IDX_newsletter_subscriber_deleted_at\" ON \"newsletter_subscriber\" (\"deleted_at\") WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"keyName": "newsletter_subscriber_pkey",
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"composite": false,
|
||||
"constraint": true,
|
||||
"primary": true,
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"checks": [],
|
||||
"foreignKeys": {},
|
||||
"nativeEnums": {}
|
||||
}
|
||||
],
|
||||
"nativeEnums": {}
|
||||
}
|
||||
16
src/modules/newsletter/migrations/Migration20261026122030.ts
Normal file
16
src/modules/newsletter/migrations/Migration20261026122030.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Migration } from "@medusajs/framework/mikro-orm/migrations";
|
||||
|
||||
export class Migration20261026122030 extends Migration {
|
||||
|
||||
override async up(): Promise<void> {
|
||||
this.addSql(`alter table if exists "newsletter_subscriber" drop constraint if exists "newsletter_subscriber_email_unique";`);
|
||||
this.addSql(`create table if not exists "newsletter_subscriber" ("id" text not null, "email" text not null, "source" text null, "status" text check ("status" in ('subscribed', 'unsubscribed')) not null default 'subscribed', "created_at" timestamptz not null default now(), "updated_at" timestamptz not null default now(), "deleted_at" timestamptz null, constraint "newsletter_subscriber_pkey" primary key ("id"));`);
|
||||
this.addSql(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_newsletter_subscriber_email_unique" ON "newsletter_subscriber" ("email") WHERE deleted_at IS NULL;`);
|
||||
this.addSql(`CREATE INDEX IF NOT EXISTS "IDX_newsletter_subscriber_deleted_at" ON "newsletter_subscriber" ("deleted_at") WHERE deleted_at IS NULL;`);
|
||||
}
|
||||
|
||||
override async down(): Promise<void> {
|
||||
this.addSql(`drop table if exists "newsletter_subscriber" cascade;`);
|
||||
}
|
||||
|
||||
}
|
||||
10
src/modules/newsletter/models/subscriber.ts
Normal file
10
src/modules/newsletter/models/subscriber.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { model } from "@medusajs/framework/utils"
|
||||
|
||||
const Subscriber = model.define("newsletter_subscriber", {
|
||||
id: model.id({ prefix: "nlsub" }).primaryKey(),
|
||||
email: model.text().unique(),
|
||||
source: model.text().nullable(),
|
||||
status: model.enum(["subscribed", "unsubscribed"]).default("subscribed"),
|
||||
})
|
||||
|
||||
export default Subscriber
|
||||
9
src/modules/newsletter/service.ts
Normal file
9
src/modules/newsletter/service.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { MedusaService } from "@medusajs/framework/utils"
|
||||
|
||||
import Subscriber from "./models/subscriber"
|
||||
|
||||
class NewsletterModuleService extends MedusaService({
|
||||
Subscriber,
|
||||
}) {}
|
||||
|
||||
export default NewsletterModuleService
|
||||
7
src/modules/smtp-notification/index.ts
Normal file
7
src/modules/smtp-notification/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { ModuleProvider, Modules } from "@medusajs/framework/utils"
|
||||
|
||||
import SmtpNotificationProviderService from "./service"
|
||||
|
||||
export default ModuleProvider(Modules.NOTIFICATION, {
|
||||
services: [SmtpNotificationProviderService],
|
||||
})
|
||||
88
src/modules/smtp-notification/service.ts
Normal file
88
src/modules/smtp-notification/service.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import {
|
||||
AbstractNotificationProviderService,
|
||||
MedusaError,
|
||||
} from "@medusajs/framework/utils"
|
||||
import type { Logger, ProviderSendNotificationDTO } from "@medusajs/framework/types"
|
||||
import nodemailer, { Transporter } from "nodemailer"
|
||||
|
||||
type Options = {
|
||||
host: string
|
||||
port?: number
|
||||
secure?: boolean
|
||||
user?: string
|
||||
pass?: string
|
||||
from: string
|
||||
}
|
||||
|
||||
type InjectedDependencies = {
|
||||
logger: Logger
|
||||
}
|
||||
|
||||
/**
|
||||
* Email notification provider over plain SMTP, so any mail service works
|
||||
* (Resend, SendGrid, Mailgun, Gmail, ...) by changing the SMTP_* env vars.
|
||||
*
|
||||
* Expects `notification.data` to carry `subject`, `html` and optionally `text`
|
||||
* - templates are rendered by the subscriber that creates the notification.
|
||||
*/
|
||||
class SmtpNotificationProviderService extends AbstractNotificationProviderService {
|
||||
static identifier = "smtp-notification"
|
||||
|
||||
protected transporter_: Transporter
|
||||
protected options_: Options
|
||||
protected logger_: Logger
|
||||
|
||||
constructor({ logger }: InjectedDependencies, options: Options) {
|
||||
super()
|
||||
this.logger_ = logger
|
||||
this.options_ = options
|
||||
this.transporter_ = nodemailer.createTransport({
|
||||
host: options.host,
|
||||
port: options.port ?? 587,
|
||||
secure: options.secure ?? options.port === 465,
|
||||
auth: options.user ? { user: options.user, pass: options.pass } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
static validateOptions(options: Record<any, any>) {
|
||||
if (!options?.host || !options?.from) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"smtp-notification requires the `host` and `from` options"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async send(
|
||||
notification: ProviderSendNotificationDTO
|
||||
): Promise<{ id?: string }> {
|
||||
const { subject, html, text } = (notification.data ?? {}) as {
|
||||
subject?: string
|
||||
html?: string
|
||||
text?: string
|
||||
}
|
||||
|
||||
if (!subject || (!html && !text)) {
|
||||
throw new MedusaError(
|
||||
MedusaError.Types.INVALID_DATA,
|
||||
"Notification data must include `subject` and `html` or `text`"
|
||||
)
|
||||
}
|
||||
|
||||
const info = await this.transporter_.sendMail({
|
||||
from: notification.from || this.options_.from,
|
||||
to: notification.to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
})
|
||||
|
||||
this.logger_.info(
|
||||
`[smtp-notification] sent "${subject}" to ${notification.to} (${info.messageId})`
|
||||
)
|
||||
|
||||
return { id: info.messageId }
|
||||
}
|
||||
}
|
||||
|
||||
export default SmtpNotificationProviderService
|
||||
181
src/scripts/add-vietnam-region.ts
Normal file
181
src/scripts/add-vietnam-region.ts
Normal file
@ -0,0 +1,181 @@
|
||||
import { ExecArgs } from "@medusajs/framework/types"
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
Modules,
|
||||
} from "@medusajs/framework/utils"
|
||||
import {
|
||||
createRegionsWorkflow,
|
||||
createShippingOptionsWorkflow,
|
||||
createTaxRegionsWorkflow,
|
||||
updateStoresWorkflow,
|
||||
} from "@medusajs/medusa/core-flows"
|
||||
|
||||
/**
|
||||
* Adds Vietnam as a sellable region and makes it the store default.
|
||||
*
|
||||
* Run once: npx medusa exec ./src/scripts/add-vietnam-region.ts
|
||||
*
|
||||
* Idempotent - every step checks whether it was already done. The region uses
|
||||
* USD because that is the store's default currency and the one existing
|
||||
* product prices are set in; change it in Admin -> Settings -> Regions if
|
||||
* VND prices are added later.
|
||||
*/
|
||||
export default async function addVietnamRegion({ container }: ExecArgs) {
|
||||
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
|
||||
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
||||
const fulfillment = container.resolve(Modules.FULFILLMENT)
|
||||
|
||||
const CURRENCY = "usd"
|
||||
|
||||
// 1. Region ---------------------------------------------------------------
|
||||
const { data: regions } = await query.graph({
|
||||
entity: "region",
|
||||
fields: ["id", "name", "countries.iso_2"],
|
||||
})
|
||||
let region = regions.find((r: any) =>
|
||||
(r.countries ?? []).some((c: any) => c.iso_2 === "vn")
|
||||
)
|
||||
|
||||
if (region) {
|
||||
logger.info(`Vietnam region already exists (${region.id}) - skipping.`)
|
||||
} else {
|
||||
const { result } = await createRegionsWorkflow(container).run({
|
||||
input: {
|
||||
regions: [
|
||||
{
|
||||
name: "Vietnam",
|
||||
currency_code: CURRENCY,
|
||||
countries: ["vn"],
|
||||
payment_providers: ["pp_system_default"],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
region = result[0] as any
|
||||
logger.info(`Created Vietnam region (${region!.id}).`)
|
||||
}
|
||||
|
||||
// 2. Tax region -----------------------------------------------------------
|
||||
const { data: taxRegions } = await query.graph({
|
||||
entity: "tax_region",
|
||||
fields: ["id", "country_code"],
|
||||
filters: { country_code: "vn" },
|
||||
})
|
||||
if (!taxRegions.length) {
|
||||
await createTaxRegionsWorkflow(container).run({
|
||||
input: [{ country_code: "vn", provider_id: "tp_system" }],
|
||||
})
|
||||
logger.info("Created Vietnam tax region.")
|
||||
}
|
||||
|
||||
// 3. Shipping: service zone + options on the existing warehouse ----------
|
||||
const { data: locations } = await query.graph({
|
||||
entity: "stock_location",
|
||||
fields: [
|
||||
"id",
|
||||
"name",
|
||||
"fulfillment_sets.id",
|
||||
"fulfillment_sets.type",
|
||||
"fulfillment_sets.service_zones.id",
|
||||
"fulfillment_sets.service_zones.name",
|
||||
],
|
||||
})
|
||||
const location: any = locations[0]
|
||||
const fulfillmentSet = (location?.fulfillment_sets ?? []).find(
|
||||
(s: any) => s.type === "shipping"
|
||||
)
|
||||
|
||||
if (!fulfillmentSet) {
|
||||
logger.warn(
|
||||
"No shipping fulfillment set found - create a stock location with shipping in the admin, then re-run."
|
||||
)
|
||||
} else {
|
||||
let zone = (fulfillmentSet.service_zones ?? []).find(
|
||||
(z: any) => z.name === "Vietnam"
|
||||
)
|
||||
if (!zone) {
|
||||
zone = await fulfillment.createServiceZones({
|
||||
name: "Vietnam",
|
||||
fulfillment_set_id: fulfillmentSet.id,
|
||||
geo_zones: [{ country_code: "vn", type: "country" }],
|
||||
})
|
||||
logger.info(`Created Vietnam service zone (${zone.id}).`)
|
||||
}
|
||||
|
||||
const { data: options } = await query.graph({
|
||||
entity: "shipping_option",
|
||||
fields: [
|
||||
"id",
|
||||
"name",
|
||||
"service_zone_id",
|
||||
"shipping_profile_id",
|
||||
"provider_id",
|
||||
"type.*",
|
||||
"prices.amount",
|
||||
"prices.currency_code",
|
||||
],
|
||||
})
|
||||
const existingInZone = options.filter((o: any) => o.service_zone_id === zone.id)
|
||||
|
||||
if (existingInZone.length) {
|
||||
logger.info("Vietnam shipping options already exist - skipping.")
|
||||
} else {
|
||||
// Mirror the existing Standard/Express options (same profile, provider,
|
||||
// type and flat price) onto the Vietnam zone.
|
||||
const templates = options.filter((o: any) =>
|
||||
["Standard Shipping", "Express Shipping"].includes(o.name)
|
||||
)
|
||||
const input = templates.map((o: any) => {
|
||||
const base =
|
||||
o.prices.find((p: any) => p.currency_code === CURRENCY)?.amount ?? 10
|
||||
return {
|
||||
name: o.name,
|
||||
price_type: "flat" as const,
|
||||
provider_id: o.provider_id,
|
||||
service_zone_id: zone.id,
|
||||
shipping_profile_id: o.shipping_profile_id,
|
||||
type: {
|
||||
label: o.type.label,
|
||||
description: o.type.description,
|
||||
code: o.type.code,
|
||||
},
|
||||
prices: [
|
||||
{ currency_code: CURRENCY, amount: base },
|
||||
{ region_id: region!.id, amount: base },
|
||||
],
|
||||
rules: [
|
||||
{ attribute: "enabled_in_store", value: "true", operator: "eq" as const },
|
||||
{ attribute: "is_return", value: "false", operator: "eq" as const },
|
||||
],
|
||||
}
|
||||
})
|
||||
if (input.length) {
|
||||
await createShippingOptionsWorkflow(container).run({ input })
|
||||
logger.info(`Created ${input.length} Vietnam shipping option(s).`)
|
||||
} else {
|
||||
logger.warn(
|
||||
"No Standard/Express template options found - add shipping options for Vietnam in the admin."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Store defaults -------------------------------------------------------
|
||||
const { data: stores } = await query.graph({
|
||||
entity: "store",
|
||||
fields: ["id", "default_region_id", "default_location_id"],
|
||||
})
|
||||
const store: any = stores[0]
|
||||
const update: Record<string, string> = {}
|
||||
if (store.default_region_id !== region!.id) update.default_region_id = region!.id
|
||||
if (!store.default_location_id && location) update.default_location_id = location.id
|
||||
|
||||
if (Object.keys(update).length) {
|
||||
await updateStoresWorkflow(container).run({
|
||||
input: { selector: { id: store.id }, update },
|
||||
})
|
||||
logger.info(`Store defaults updated: ${JSON.stringify(update)}`)
|
||||
}
|
||||
|
||||
logger.info("Done. Restart the storefront so it picks up the new region.")
|
||||
}
|
||||
61
src/subscribers/README.md
Normal file
61
src/subscribers/README.md
Normal file
@ -0,0 +1,61 @@
|
||||
# Custom subscribers
|
||||
|
||||
Subscribers handle events emitted in the Medusa application.
|
||||
|
||||
> Learn more about Subscribers in [this documentation](https://docs.medusajs.com/learn/fundamentals/events-and-subscribers).
|
||||
|
||||
The subscriber is created in a TypeScript or JavaScript file under the `src/subscribers` directory.
|
||||
|
||||
For example, create the file `src/subscribers/product-created.ts` with the following content:
|
||||
|
||||
```ts
|
||||
import {
|
||||
type SubscriberConfig,
|
||||
} from "@medusajs/framework"
|
||||
|
||||
// subscriber function
|
||||
export default async function productCreateHandler() {
|
||||
console.log("A product was created")
|
||||
}
|
||||
|
||||
// subscriber config
|
||||
export const config: SubscriberConfig = {
|
||||
event: "product.created",
|
||||
}
|
||||
```
|
||||
|
||||
A subscriber file must export:
|
||||
|
||||
- The subscriber function that is an asynchronous function executed whenever the associated event is triggered.
|
||||
- A configuration object defining the event this subscriber is listening to.
|
||||
|
||||
## Subscriber Parameters
|
||||
|
||||
A subscriber receives an object having the following properties:
|
||||
|
||||
- `event`: An object holding the event's details. It has a `data` property, which is the event's data payload.
|
||||
- `container`: The Medusa container. Use it to resolve modules' main services and other registered resources.
|
||||
|
||||
```ts
|
||||
import type {
|
||||
SubscriberArgs,
|
||||
SubscriberConfig,
|
||||
} from "@medusajs/framework"
|
||||
|
||||
export default async function productCreateHandler({
|
||||
event: { data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string }>) {
|
||||
const productId = data.id
|
||||
|
||||
const productModuleService = container.resolve("product")
|
||||
|
||||
const product = await productModuleService.retrieveProduct(productId)
|
||||
|
||||
console.log(`The product ${product.title} was created`)
|
||||
}
|
||||
|
||||
export const config: SubscriberConfig = {
|
||||
event: "product.created",
|
||||
}
|
||||
```
|
||||
81
src/subscribers/order-placed.ts
Normal file
81
src/subscribers/order-placed.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import type { SubscriberArgs, SubscriberConfig } from "@medusajs/framework"
|
||||
import { ContainerRegistrationKeys, Modules } from "@medusajs/framework/utils"
|
||||
|
||||
import { buildOrderConfirmationEmail } from "../lib/order-email"
|
||||
|
||||
/**
|
||||
* Sends the order-confirmation email when an order is placed.
|
||||
* No-op (with a warning) until SMTP_HOST / SMTP_FROM are configured.
|
||||
*/
|
||||
export default async function orderPlacedHandler({
|
||||
event: { data },
|
||||
container,
|
||||
}: SubscriberArgs<{ id: string }>) {
|
||||
const logger = container.resolve(ContainerRegistrationKeys.LOGGER)
|
||||
|
||||
if (!process.env.SMTP_HOST || !process.env.SMTP_FROM) {
|
||||
logger.warn(
|
||||
`[order-email] SMTP_HOST/SMTP_FROM not set - skipped confirmation email for order ${data.id}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const query = container.resolve(ContainerRegistrationKeys.QUERY)
|
||||
const notificationService = container.resolve(Modules.NOTIFICATION)
|
||||
|
||||
const {
|
||||
data: [order],
|
||||
} = await query.graph({
|
||||
entity: "order",
|
||||
fields: [
|
||||
"id",
|
||||
"display_id",
|
||||
"email",
|
||||
"currency_code",
|
||||
"created_at",
|
||||
"subtotal",
|
||||
"shipping_total",
|
||||
"discount_total",
|
||||
"tax_total",
|
||||
"total",
|
||||
"items.title",
|
||||
"items.subtitle",
|
||||
"items.variant_sku",
|
||||
"items.quantity",
|
||||
"items.unit_price",
|
||||
"shipping_address.*",
|
||||
"shipping_methods.name",
|
||||
],
|
||||
filters: { id: data.id },
|
||||
})
|
||||
|
||||
if (!order?.email) {
|
||||
logger.warn(`[order-email] order ${data.id} has no email - skipped`)
|
||||
return
|
||||
}
|
||||
|
||||
const storeName = process.env.STORE_NAME || "HOO Store"
|
||||
const { subject, html, text } = buildOrderConfirmationEmail(
|
||||
order as any,
|
||||
storeName,
|
||||
process.env.STORE_URL
|
||||
)
|
||||
|
||||
await notificationService.createNotifications({
|
||||
to: order.email,
|
||||
channel: "email",
|
||||
template: "order-placed",
|
||||
resource_id: order.id,
|
||||
resource_type: "order",
|
||||
data: { subject, html, text },
|
||||
})
|
||||
} catch (error) {
|
||||
// Never fail order placement because the email could not be sent.
|
||||
logger.error(`[order-email] failed for order ${data.id}: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const config: SubscriberConfig = {
|
||||
event: "order.placed",
|
||||
}
|
||||
81
src/workflows/README.md
Normal file
81
src/workflows/README.md
Normal file
@ -0,0 +1,81 @@
|
||||
# Custom Workflows
|
||||
|
||||
A workflow is a series of queries and actions that complete a task.
|
||||
|
||||
The workflow is created in a TypeScript or JavaScript file under the `src/workflows` directory.
|
||||
|
||||
> Learn more about workflows in [this documentation](https://docs.medusajs.com/learn/fundamentals/workflows).
|
||||
|
||||
For example:
|
||||
|
||||
```ts
|
||||
import {
|
||||
createStep,
|
||||
createWorkflow,
|
||||
WorkflowResponse,
|
||||
StepResponse,
|
||||
} from "@medusajs/framework/workflows-sdk"
|
||||
|
||||
const step1 = createStep("step-1", async () => {
|
||||
return new StepResponse(`Hello from step one!`)
|
||||
})
|
||||
|
||||
type WorkflowInput = {
|
||||
name: string
|
||||
}
|
||||
|
||||
const step2 = createStep(
|
||||
"step-2",
|
||||
async ({ name }: WorkflowInput) => {
|
||||
return new StepResponse(`Hello ${name} from step two!`)
|
||||
}
|
||||
)
|
||||
|
||||
type WorkflowOutput = {
|
||||
message1: string
|
||||
message2: string
|
||||
}
|
||||
|
||||
const helloWorldWorkflow = createWorkflow(
|
||||
"hello-world",
|
||||
(input: WorkflowInput) => {
|
||||
const greeting1 = step1()
|
||||
const greeting2 = step2(input)
|
||||
|
||||
return new WorkflowResponse({
|
||||
message1: greeting1,
|
||||
message2: greeting2
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
export default helloWorldWorkflow
|
||||
```
|
||||
|
||||
## Execute Workflow
|
||||
|
||||
You can execute the workflow from other resources, such as API routes, scheduled jobs, or subscribers.
|
||||
|
||||
For example, to execute the workflow in an API route:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MedusaRequest,
|
||||
MedusaResponse,
|
||||
} from "@medusajs/framework"
|
||||
import myWorkflow from "../../../workflows/hello-world"
|
||||
|
||||
export async function GET(
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse
|
||||
) {
|
||||
const { result } = await myWorkflow(req.scope)
|
||||
.run({
|
||||
input: {
|
||||
name: req.query.name as string,
|
||||
},
|
||||
})
|
||||
|
||||
res.send(result)
|
||||
}
|
||||
```
|
||||
36
tsconfig.json
Normal file
36
tsconfig.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"esModuleInterop": true,
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"declaration": false,
|
||||
"sourceMap": false,
|
||||
"inlineSourceMap": true,
|
||||
"outDir": "./.medusa/server",
|
||||
"rootDir": "./",
|
||||
"jsx": "react-jsx",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"checkJs": false,
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"ts-node": {
|
||||
"swc": true
|
||||
},
|
||||
"include": [
|
||||
"**/*",
|
||||
".medusa/types/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".medusa/server",
|
||||
".medusa/admin",
|
||||
".cache",
|
||||
"eslint.config.*",
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user