Initial commit
commit
d805cefeae
@ -0,0 +1,12 @@
|
||||
let ignore = [`**/dist`]
|
||||
|
||||
// Jest needs to compile this code, but generally we don't want this copied
|
||||
// to output folders
|
||||
if (process.env.NODE_ENV !== `test`) {
|
||||
ignore.push(`**/__tests__`)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
presets: [["babel-preset-medusa-package"], ["@babel/preset-typescript"]],
|
||||
ignore,
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
JWT_SECRET=something
|
||||
COOKIE_SECRET=something
|
||||
|
||||
DATABASE_TYPE="postgres"
|
||||
REDIS_URL=redis://localhost:6379
|
||||
@ -0,0 +1,21 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
allow:
|
||||
- dependency-type: production
|
||||
groups:
|
||||
medusa:
|
||||
patterns:
|
||||
- "@medusajs*"
|
||||
- "medusa*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
ignore:
|
||||
- dependency-name: "@medusajs*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "medusa*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
@ -0,0 +1,27 @@
|
||||
/dist
|
||||
.env
|
||||
.DS_Store
|
||||
/uploads
|
||||
/node_modules
|
||||
yarn-error.log
|
||||
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
coverage
|
||||
|
||||
!src/**
|
||||
|
||||
./tsconfig.tsbuildinfo
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
medusa-db.sql
|
||||
build
|
||||
.cache
|
||||
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
@ -0,0 +1,141 @@
|
||||
{
|
||||
"store": {
|
||||
"currencies": [
|
||||
"eur",
|
||||
"usd"
|
||||
]
|
||||
},
|
||||
"users": [],
|
||||
"regions": [
|
||||
{
|
||||
"id": "test-region-eu",
|
||||
"name": "EU",
|
||||
"currency_code": "eur",
|
||||
"tax_rate": 0,
|
||||
"payment_providers": [
|
||||
"manual"
|
||||
],
|
||||
"fulfillment_providers": [
|
||||
"manual"
|
||||
],
|
||||
"countries": [
|
||||
"gb",
|
||||
"de",
|
||||
"dk",
|
||||
"se",
|
||||
"fr",
|
||||
"es",
|
||||
"it"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "test-region-na",
|
||||
"name": "NA",
|
||||
"currency_code": "usd",
|
||||
"tax_rate": 0,
|
||||
"payment_providers": [
|
||||
"manual"
|
||||
],
|
||||
"fulfillment_providers": [
|
||||
"manual"
|
||||
],
|
||||
"countries": [
|
||||
"us",
|
||||
"ca"
|
||||
]
|
||||
}
|
||||
],
|
||||
"shipping_options": [
|
||||
{
|
||||
"name": "PostFake Standard",
|
||||
"region_id": "test-region-eu",
|
||||
"provider_id": "manual",
|
||||
"data": {
|
||||
"id": "manual-fulfillment"
|
||||
},
|
||||
"price_type": "flat_rate",
|
||||
"amount": 1000
|
||||
},
|
||||
{
|
||||
"name": "PostFake Express",
|
||||
"region_id": "test-region-eu",
|
||||
"provider_id": "manual",
|
||||
"data": {
|
||||
"id": "manual-fulfillment"
|
||||
},
|
||||
"price_type": "flat_rate",
|
||||
"amount": 1500
|
||||
},
|
||||
{
|
||||
"name": "PostFake Return",
|
||||
"region_id": "test-region-eu",
|
||||
"provider_id": "manual",
|
||||
"data": {
|
||||
"id": "manual-fulfillment"
|
||||
},
|
||||
"price_type": "flat_rate",
|
||||
"is_return": true,
|
||||
"amount": 1000
|
||||
},
|
||||
{
|
||||
"name": "I want to return it myself",
|
||||
"region_id": "test-region-eu",
|
||||
"provider_id": "manual",
|
||||
"data": {
|
||||
"id": "manual-fulfillment"
|
||||
},
|
||||
"price_type": "flat_rate",
|
||||
"is_return": true,
|
||||
"amount": 0
|
||||
},
|
||||
{
|
||||
"name": "FakeEx Standard",
|
||||
"region_id": "test-region-na",
|
||||
"provider_id": "manual",
|
||||
"data": {
|
||||
"id": "manual-fulfillment"
|
||||
},
|
||||
"price_type": "flat_rate",
|
||||
"amount": 800
|
||||
},
|
||||
{
|
||||
"name": "FakeEx Express",
|
||||
"region_id": "test-region-na",
|
||||
"provider_id": "manual",
|
||||
"data": {
|
||||
"id": "manual-fulfillment"
|
||||
},
|
||||
"price_type": "flat_rate",
|
||||
"amount": 1200
|
||||
},
|
||||
{
|
||||
"name": "FakeEx Return",
|
||||
"region_id": "test-region-na",
|
||||
"provider_id": "manual",
|
||||
"data": {
|
||||
"id": "manual-fulfillment"
|
||||
},
|
||||
"price_type": "flat_rate",
|
||||
"is_return": true,
|
||||
"amount": 800
|
||||
},
|
||||
{
|
||||
"name": "I want to return it myself",
|
||||
"region_id": "test-region-na",
|
||||
"provider_id": "manual",
|
||||
"data": {
|
||||
"id": "manual-fulfillment"
|
||||
},
|
||||
"price_type": "flat_rate",
|
||||
"is_return": true,
|
||||
"amount": 0
|
||||
}
|
||||
],
|
||||
"products": [],
|
||||
"categories": [],
|
||||
"publishable_api_keys": [
|
||||
{
|
||||
"title": "Development"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,50 @@
|
||||
const express = require("express")
|
||||
const { GracefulShutdownServer } = require("medusa-core-utils")
|
||||
|
||||
const loaders = require("@medusajs/medusa/dist/loaders/index").default
|
||||
|
||||
;(async() => {
|
||||
async function start() {
|
||||
const app = express()
|
||||
const directory = process.cwd()
|
||||
|
||||
try {
|
||||
const { container } = await loaders({
|
||||
directory,
|
||||
expressApp: app
|
||||
})
|
||||
const configModule = container.resolve("configModule")
|
||||
const port = process.env.PORT ?? configModule.projectConfig.port ?? 9000
|
||||
|
||||
const server = GracefulShutdownServer.create(
|
||||
app.listen(port, (err) => {
|
||||
if (err) {
|
||||
return
|
||||
}
|
||||
console.log(`Server is ready on port: ${port}`)
|
||||
})
|
||||
)
|
||||
|
||||
// Handle graceful shutdown
|
||||
const gracefulShutDown = () => {
|
||||
server
|
||||
.shutdown()
|
||||
.then(() => {
|
||||
console.info("Gracefully stopping the server.")
|
||||
process.exit(0)
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Error received when shutting down the server.", e)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
process.on("SIGTERM", gracefulShutDown)
|
||||
process.on("SIGINT", gracefulShutDown)
|
||||
} catch (err) {
|
||||
console.error("Error starting server", err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
await start()
|
||||
})()
|
||||
@ -0,0 +1,88 @@
|
||||
const dotenv = require("dotenv");
|
||||
|
||||
let ENV_FILE_NAME = "";
|
||||
switch (process.env.NODE_ENV) {
|
||||
case "production":
|
||||
ENV_FILE_NAME = ".env.production";
|
||||
break;
|
||||
case "staging":
|
||||
ENV_FILE_NAME = ".env.staging";
|
||||
break;
|
||||
case "test":
|
||||
ENV_FILE_NAME = ".env.test";
|
||||
break;
|
||||
case "development":
|
||||
default:
|
||||
ENV_FILE_NAME = ".env";
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
dotenv.config({ path: process.cwd() + "/" + ENV_FILE_NAME });
|
||||
} catch (e) {}
|
||||
|
||||
// CORS when consuming Medusa from admin
|
||||
const ADMIN_CORS =
|
||||
process.env.ADMIN_CORS || "http://localhost:7000,http://localhost:7001";
|
||||
|
||||
// CORS to avoid issues when consuming Medusa from a client
|
||||
const STORE_CORS = process.env.STORE_CORS || "http://localhost:8000";
|
||||
|
||||
const DATABASE_URL =
|
||||
process.env.DATABASE_URL || "postgres://localhost/medusa-starter-default";
|
||||
|
||||
const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
|
||||
|
||||
const plugins = [
|
||||
`medusa-fulfillment-manual`,
|
||||
`medusa-payment-manual`,
|
||||
{
|
||||
resolve: `@medusajs/file-local`,
|
||||
options: {
|
||||
upload_dir: "uploads",
|
||||
},
|
||||
},
|
||||
{
|
||||
resolve: "@medusajs/admin",
|
||||
/** @type {import('@medusajs/admin').PluginOptions} */
|
||||
options: {
|
||||
autoRebuild: true,
|
||||
develop: {
|
||||
open: process.env.OPEN_BROWSER !== "false",
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const modules = {
|
||||
/*eventBus: {
|
||||
resolve: "@medusajs/event-bus-redis",
|
||||
options: {
|
||||
redisUrl: REDIS_URL
|
||||
}
|
||||
},
|
||||
cacheService: {
|
||||
resolve: "@medusajs/cache-redis",
|
||||
options: {
|
||||
redisUrl: REDIS_URL
|
||||
}
|
||||
},*/
|
||||
};
|
||||
|
||||
/** @type {import('@medusajs/medusa').ConfigModule["projectConfig"]} */
|
||||
const projectConfig = {
|
||||
jwtSecret: process.env.JWT_SECRET,
|
||||
cookieSecret: process.env.COOKIE_SECRET,
|
||||
store_cors: STORE_CORS,
|
||||
database_url: DATABASE_URL,
|
||||
admin_cors: ADMIN_CORS,
|
||||
// Uncomment the following lines to enable REDIS
|
||||
// redis_url: REDIS_URL
|
||||
};
|
||||
|
||||
/** @type {import('@medusajs/medusa').ConfigModule} */
|
||||
module.exports = {
|
||||
projectConfig,
|
||||
plugins,
|
||||
modules,
|
||||
};
|
||||
@ -0,0 +1,90 @@
|
||||
{
|
||||
"name": "medusa-starter-default",
|
||||
"version": "0.0.1",
|
||||
"description": "A starter for Medusa projects.",
|
||||
"author": "Medusa (https://medusajs.com)",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"sqlite",
|
||||
"postgres",
|
||||
"typescript",
|
||||
"ecommerce",
|
||||
"headless",
|
||||
"medusa"
|
||||
],
|
||||
"scripts": {
|
||||
"clean": "cross-env ./node_modules/.bin/rimraf dist",
|
||||
"build": "cross-env npm run clean && npm run build:server && npm run build:admin",
|
||||
"build:server": "cross-env npm run clean && tsc -p tsconfig.server.json",
|
||||
"build:admin": "cross-env medusa-admin build",
|
||||
"watch": "cross-env tsc --watch",
|
||||
"test": "cross-env jest",
|
||||
"seed": "cross-env medusa seed -f ./data/seed.json",
|
||||
"start": "cross-env npm run build && medusa start",
|
||||
"start:custom": "cross-env npm run build && node --preserve-symlinks --trace-warnings index.js",
|
||||
"dev": "cross-env npm run build:server && medusa develop"
|
||||
},
|
||||
"dependencies": {
|
||||
"@medusajs/admin": "7.1.8",
|
||||
"@medusajs/cache-inmemory": "^1.8.9",
|
||||
"@medusajs/cache-redis": "^1.8.9",
|
||||
"@medusajs/event-bus-local": "^1.9.7",
|
||||
"@medusajs/event-bus-redis": "^1.8.10",
|
||||
"@medusajs/file-local": "^1.0.2",
|
||||
"@medusajs/medusa": "1.18.1",
|
||||
"@tanstack/react-query": "4.22.0",
|
||||
"body-parser": "^1.19.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "16.3.1",
|
||||
"express": "^4.17.2",
|
||||
"medusa-fulfillment-manual": "^1.1.38",
|
||||
"medusa-interfaces": "^1.3.7",
|
||||
"medusa-payment-manual": "^1.0.24",
|
||||
"medusa-payment-stripe": "^6.0.5",
|
||||
"prism-react-renderer": "^2.0.4",
|
||||
"typeorm": "^0.3.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.14.3",
|
||||
"@babel/core": "^7.14.3",
|
||||
"@babel/preset-typescript": "^7.21.4",
|
||||
"@medusajs/medusa-cli": "^1.3.21",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "^27.4.0",
|
||||
"@types/node": "^17.0.8",
|
||||
"babel-preset-medusa-package": "^1.1.19",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^6.8.0",
|
||||
"jest": "^27.3.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"ts-jest": "^27.0.7",
|
||||
"ts-loader": "^9.2.6",
|
||||
"typescript": "^4.5.2"
|
||||
},
|
||||
"jest": {
|
||||
"globals": {
|
||||
"ts-jest": {
|
||||
"tsconfig": "tsconfig.spec.json"
|
||||
}
|
||||
},
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"testPathIgnorePatterns": [
|
||||
"/node_modules/",
|
||||
"<rootDir>/node_modules/"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|js)$",
|
||||
"transform": {
|
||||
".ts": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "./coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,136 @@
|
||||
import React from "react";
|
||||
import {
|
||||
ComputerDesktopSolid,
|
||||
CurrencyDollarSolid,
|
||||
NextJs
|
||||
} from "@medusajs/icons";
|
||||
import { IconBadge, Heading, Text } from "@medusajs/ui";
|
||||
|
||||
const OrderDetailDefault = () => {
|
||||
return (
|
||||
<>
|
||||
<Text size="small" className="mb-6">
|
||||
You finished the setup guide 🎉 You now have your first order. Feel free
|
||||
to play around with the order management functionalities, such as
|
||||
capturing payment, creating fulfillments, and more.
|
||||
</Text>
|
||||
<Heading
|
||||
level="h2"
|
||||
className="text-ui-fg-base pt-6 border-t border-ui-border-base border-solid mb-2"
|
||||
>
|
||||
Start developing with Medusa
|
||||
</Heading>
|
||||
<Text size="small">
|
||||
Medusa is a completely customizable commerce solution. We've curated
|
||||
some essential guides to kickstart your development with Medusa.
|
||||
</Text>
|
||||
<div className="grid grid-cols-3 gap-4 mt-6 pb-6 mb-6 border-b border-ui-border-base border-solid auto-rows-fr">
|
||||
<a
|
||||
href={`https://docs.medusajs.com/modules/overview?ref=onboarding`}
|
||||
target="_blank"
|
||||
className="flex"
|
||||
>
|
||||
<div className="p-3 rounded-rounded items-start flex bg-ui-bg-subtle shadow-elevation-card-rest hover:shadow-elevation-card-hover">
|
||||
<div className="mr-4">
|
||||
<div className="bg-ui-bg-base rounded-lg border border-ui-border-strong p-1 flex justify-center items-center">
|
||||
<IconBadge>
|
||||
<CurrencyDollarSolid />
|
||||
</IconBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text
|
||||
size="xsmall"
|
||||
weight="plus"
|
||||
className="mb-1 text-ui-fg-base"
|
||||
>
|
||||
Add Commerce Features
|
||||
</Text>
|
||||
<Text size="small">
|
||||
Learn about all available commerce features and how to
|
||||
add them in your storefront
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="https://docs.medusajs.com/recipes/?ref=onboarding"
|
||||
target="_blank"
|
||||
className="flex"
|
||||
>
|
||||
<div className="p-3 rounded-rounded items-start flex bg-ui-bg-subtle shadow-elevation-card-rest hover:shadow-elevation-card-hover">
|
||||
<div className="mr-4">
|
||||
<div className="bg-ui-bg-base rounded-lg border border-ui-border-strong p-1 flex justify-center items-center">
|
||||
<IconBadge>
|
||||
<ComputerDesktopSolid />
|
||||
</IconBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text
|
||||
size="xsmall"
|
||||
weight="plus"
|
||||
className="mb-1 text-ui-fg-base"
|
||||
>
|
||||
Build Custom Use Cases
|
||||
</Text>
|
||||
<Text size="small">
|
||||
Build a marketplace, subscription-based purchases,
|
||||
or your custom use-cases.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href={`https://docs.medusajs.com/starters/nextjs-medusa-starter?ref=onboarding`}
|
||||
target="_blank"
|
||||
className="flex"
|
||||
>
|
||||
<div className="p-3 rounded-rounded items-start flex bg-ui-bg-subtle shadow-elevation-card-rest hover:shadow-elevation-card-hover">
|
||||
<div className="mr-4">
|
||||
<div className="bg-ui-bg-base rounded-lg border border-ui-border-strong p-1 flex justify-center items-center">
|
||||
<IconBadge>
|
||||
<NextJs />
|
||||
</IconBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text
|
||||
size="xsmall"
|
||||
weight="plus"
|
||||
className="mb-1 text-ui-fg-base"
|
||||
>
|
||||
Install Next.js Quickstart
|
||||
</Text>
|
||||
<Text size="small">
|
||||
Install and use the Next.js storefront with
|
||||
your commerce store.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
You can find more useful guides in{" "}
|
||||
<a
|
||||
href="https://docs.medusajs.com/?ref=onboarding"
|
||||
target="_blank"
|
||||
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
|
||||
>
|
||||
our documentation
|
||||
</a>
|
||||
. If you like Medusa, please{" "}
|
||||
<a
|
||||
href="https://github.com/medusajs/medusa"
|
||||
target="_blank"
|
||||
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
|
||||
>
|
||||
star us on GitHub
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrderDetailDefault;
|
||||
@ -0,0 +1,90 @@
|
||||
import React, { useEffect, useMemo } from "react"
|
||||
import {
|
||||
useAdminPublishableApiKeys,
|
||||
useAdminCreatePublishableApiKey
|
||||
} from "medusa-react"
|
||||
import { StepContentProps } from "../../../../widgets/onboarding-flow/onboarding-flow"
|
||||
import { Button, CodeBlock, Text } from "@medusajs/ui"
|
||||
|
||||
const ProductDetailDefault = ({ onNext, isComplete, data }: StepContentProps) => {
|
||||
const { publishable_api_keys: keys, isLoading, refetch } = useAdminPublishableApiKeys({
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
});
|
||||
const createPublishableApiKey = useAdminCreatePublishableApiKey()
|
||||
|
||||
const api_key = useMemo(() => keys?.[0]?.id || "", [keys])
|
||||
const backendUrl = process.env.MEDUSA_BACKEND_URL === "/" || process.env.MEDUSA_ADMIN_BACKEND_URL === "/" ?
|
||||
location.origin :
|
||||
process.env.MEDUSA_BACKEND_URL || process.env.MEDUSA_ADMIN_BACKEND_URL || "http://location:9000"
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !keys?.length) {
|
||||
createPublishableApiKey.mutate({
|
||||
"title": "Development"
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
refetch()
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [isLoading, keys])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text>On this page, you can view your product's details and edit them.</Text>
|
||||
<Text>
|
||||
You can preview your product using Medusa's Store APIs. You can copy any
|
||||
of the following code snippets to try it out.
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
{!isLoading && (
|
||||
<CodeBlock snippets={[
|
||||
{
|
||||
label: "cURL",
|
||||
language: "bash",
|
||||
code: `curl "${backendUrl}/store/products/${data?.product_id}"${api_key ? ` -H "x-publishable-key: ${api_key}"` : ``}`,
|
||||
},
|
||||
{
|
||||
label: "Medusa JS Client",
|
||||
language: "js",
|
||||
code: `// Install the JS Client in your storefront project: @medusajs/medusa-js\n\nimport Medusa from "@medusajs/medusa-js"\n\nconst medusa = new Medusa(${api_key ? `{ publishableApiKey: "${api_key}"}` : ``})\nconst product = await medusa.products.retrieve("${data?.product_id}")\nconsole.log(product.id)`,
|
||||
},
|
||||
{
|
||||
label: "Medusa React",
|
||||
language: "tsx",
|
||||
code: `// Install the React SDK and required dependencies in your storefront project:\n// medusa-react @tanstack/react-query @medusajs/medusa\n\nimport { useProduct } from "medusa-react"\n\nconst { product } = useProduct("${data?.product_id}")\nconsole.log(product.id)`,
|
||||
},
|
||||
{
|
||||
label: "@medusajs/product",
|
||||
language: "tsx",
|
||||
code: `// Install the Product module in a serverless project, such as a Next.js storefront: @medusajs/product\n\nimport {\ninitialize as initializeProductModule,\n} from "@medusajs/product"\n\n// in an async function, or you can use promises\nasync () => {\n // ...\n const productService = await initializeProductModule()\n const products = await productService.list({\n id: "${data?.product_id}",\n })\n\n console.log(products[0])\n}`,
|
||||
},
|
||||
]} className="my-6">
|
||||
<CodeBlock.Header />
|
||||
<CodeBlock.Body />
|
||||
</CodeBlock>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href={`${backendUrl}/store/products/${data?.product_id}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="secondary" size="base">
|
||||
Open preview in browser
|
||||
</Button>
|
||||
</a>
|
||||
{!isComplete && (
|
||||
<Button variant="primary" size="base" onClick={() => onNext()}>
|
||||
Next step
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailDefault;
|
||||
@ -0,0 +1,72 @@
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
useAdminCreateProduct,
|
||||
useAdminCreateCollection,
|
||||
useMedusa
|
||||
} from "medusa-react";
|
||||
import { StepContentProps } from "../../../../widgets/onboarding-flow/onboarding-flow";
|
||||
import { Button, Text } from "@medusajs/ui";
|
||||
import getSampleProducts from "../../../../utils/sample-products";
|
||||
import prepareRegions from "../../../../utils/prepare-region";
|
||||
|
||||
const ProductsListDefault = ({ onNext, isComplete }: StepContentProps) => {
|
||||
const { mutateAsync: createCollection, isLoading: collectionLoading } =
|
||||
useAdminCreateCollection();
|
||||
const { mutateAsync: createProduct, isLoading: productLoading } =
|
||||
useAdminCreateProduct();
|
||||
const { client } = useMedusa()
|
||||
|
||||
const isLoading = useMemo(() =>
|
||||
collectionLoading || productLoading,
|
||||
[collectionLoading, productLoading]
|
||||
);
|
||||
|
||||
const createSample = async () => {
|
||||
try {
|
||||
const { collection } = await createCollection({
|
||||
title: "Merch",
|
||||
handle: "merch",
|
||||
});
|
||||
|
||||
const regions = await prepareRegions(client)
|
||||
|
||||
const sampleProducts = getSampleProducts({
|
||||
regions,
|
||||
collection_id: collection.id
|
||||
})
|
||||
const { product } = await createProduct(sampleProducts[0]);
|
||||
onNext(product);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text className="mb-2">
|
||||
Create a product and set its general details such as title and
|
||||
description, its price, options, variants, images, and more. You'll then
|
||||
use the product to create a sample order.
|
||||
</Text>
|
||||
<Text>
|
||||
You can create a product by clicking the "New Product" button below.
|
||||
Alternatively, if you're not ready to create your own product, we can
|
||||
create a sample one for you.
|
||||
</Text>
|
||||
{!isComplete && (
|
||||
<div className="flex gap-2 mt-6">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="base"
|
||||
onClick={() => createSample()}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Create sample product
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductsListDefault;
|
||||
@ -0,0 +1,54 @@
|
||||
import { useAdminProduct } from "medusa-react";
|
||||
import { StepContentProps } from "../../../../widgets/onboarding-flow/onboarding-flow";
|
||||
import { Button, Text } from "@medusajs/ui";
|
||||
|
||||
const ProductDetailNextjs = ({ onNext, isComplete, data }: StepContentProps) => {
|
||||
const { product, isLoading: productIsLoading } = useAdminProduct(data?.product_id)
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text>
|
||||
We have now created a few sample products in your Medusa store. You can scroll down to see what the Product Detail view looks like in the Admin dashboard.
|
||||
This is also the view you use to edit existing products.
|
||||
</Text>
|
||||
<Text>
|
||||
To view the products in your store, you can visit the Next.js Storefront that was installed with <code>create-medusa-app</code>.
|
||||
</Text>
|
||||
<Text>
|
||||
The Next.js Storefront Starter is a template that helps you start building an ecommerce store with Medusa.
|
||||
You control the code for the storefront and you can customize it further to fit your specific needs.
|
||||
</Text>
|
||||
<Text>
|
||||
Click the button below to view the products in your Next.js Storefront.
|
||||
</Text>
|
||||
<Text>
|
||||
Having trouble? Click{" "}
|
||||
<a
|
||||
href="https://docs.medusajs.com/starters/nextjs-medusa-starter#troubleshooting-nextjs-storefront-not-working"
|
||||
target="_blank"
|
||||
className="text-ui-fg-interactive hover:text-ui-fg-interactive-hover"
|
||||
>
|
||||
here
|
||||
</a>.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-6">
|
||||
<a
|
||||
href={`http://localhost:8000/products/${product?.handle}?onboarding=true`}
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="primary" size="base" isLoading={productIsLoading}>
|
||||
Open Next.js Storefront
|
||||
</Button>
|
||||
</a>
|
||||
{!isComplete && (
|
||||
<Button variant="secondary" size="base" onClick={() => onNext()}>
|
||||
Next step
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailNextjs
|
||||
@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import {
|
||||
useAdminCreateProduct,
|
||||
useAdminCreateCollection,
|
||||
useMedusa
|
||||
} from "medusa-react";
|
||||
import { StepContentProps } from "../../../../widgets/onboarding-flow/onboarding-flow";
|
||||
import { Button, Text } from "@medusajs/ui";
|
||||
import { AdminPostProductsReq, Product } from "@medusajs/medusa";
|
||||
import getSampleProducts from "../../../../utils/sample-products";
|
||||
import prepareRegions from "../../../../utils/prepare-region";
|
||||
|
||||
const ProductsListNextjs = ({ onNext, isComplete }: StepContentProps) => {
|
||||
const { mutateAsync: createCollection, isLoading: collectionLoading } =
|
||||
useAdminCreateCollection();
|
||||
const { mutateAsync: createProduct, isLoading: productLoading } =
|
||||
useAdminCreateProduct();
|
||||
const { client } = useMedusa()
|
||||
|
||||
const isLoading = collectionLoading || productLoading;
|
||||
|
||||
const createSample = async () => {
|
||||
try {
|
||||
const { collection } = await createCollection({
|
||||
title: "Merch",
|
||||
handle: "merch",
|
||||
});
|
||||
|
||||
const regions = await prepareRegions(client)
|
||||
|
||||
const tryCreateProduct = async (sampleProduct: AdminPostProductsReq): Promise<Product | null> => {
|
||||
try {
|
||||
return (await createProduct(sampleProduct)).product
|
||||
} catch {
|
||||
// ignore if product already exists
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
let product: Product
|
||||
const sampleProducts = getSampleProducts({
|
||||
regions,
|
||||
collection_id: collection.id
|
||||
})
|
||||
await Promise.all(
|
||||
sampleProducts.map(async (sampleProduct, index) => {
|
||||
const createdProduct = await tryCreateProduct(sampleProduct)
|
||||
if (index === 0 && createProduct) {
|
||||
product = createdProduct
|
||||
}
|
||||
})
|
||||
)
|
||||
onNext(product);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text className="mb-2">
|
||||
Products is Medusa represent the products you sell. You can set their general details including a
|
||||
title and description. Each product has options and variants, and you can set a price for each variant.
|
||||
</Text>
|
||||
<Text>
|
||||
Click the button below to create sample products.
|
||||
</Text>
|
||||
{!isComplete && (
|
||||
<div className="flex gap-2 mt-6">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="base"
|
||||
onClick={() => createSample()}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Create sample products
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductsListNextjs;
|
||||
@ -0,0 +1,123 @@
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import React from "react";
|
||||
import { CheckCircleSolid, CircleMiniSolid } from "@medusajs/icons";
|
||||
import { Heading, Text, clx } from "@medusajs/ui";
|
||||
import ActiveCircleDottedLine from "./icons/active-circle-dotted-line";
|
||||
|
||||
type AccordionItemProps = AccordionPrimitive.AccordionItemProps & {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
tooltip?: string;
|
||||
forceMountContent?: true;
|
||||
headingSize?: "small" | "medium" | "large";
|
||||
customTrigger?: React.ReactNode;
|
||||
complete?: boolean;
|
||||
active?: boolean;
|
||||
triggerable?: boolean;
|
||||
};
|
||||
|
||||
const Accordion: React.FC<
|
||||
| (AccordionPrimitive.AccordionSingleProps &
|
||||
React.RefAttributes<HTMLDivElement>)
|
||||
| (AccordionPrimitive.AccordionMultipleProps &
|
||||
React.RefAttributes<HTMLDivElement>)
|
||||
> & {
|
||||
Item: React.FC<AccordionItemProps>;
|
||||
} = ({ children, ...props }) => {
|
||||
return (
|
||||
<AccordionPrimitive.Root {...props}>{children}</AccordionPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const Item: React.FC<AccordionItemProps> = ({
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
required,
|
||||
tooltip,
|
||||
children,
|
||||
className,
|
||||
complete,
|
||||
headingSize = "large",
|
||||
customTrigger = undefined,
|
||||
forceMountContent = undefined,
|
||||
active,
|
||||
triggerable,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
{...props}
|
||||
className={clx(
|
||||
"border-grey-20 group border-t last:mb-0",
|
||||
"py-1 px-8",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<AccordionPrimitive.Header className="px-1">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center justify-center p-[10px]">
|
||||
{complete ? (
|
||||
<CheckCircleSolid className="text-ui-fg-interactive" />
|
||||
) : (
|
||||
<>
|
||||
{active && (
|
||||
<ActiveCircleDottedLine
|
||||
size={20}
|
||||
className="text-ui-fg-interactive rounded-full"
|
||||
/>
|
||||
)}
|
||||
{!active && (
|
||||
<CircleMiniSolid className="text-ui-fg-muted" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Heading level="h3" className={clx("text-ui-fg-base")}>
|
||||
{title}
|
||||
</Heading>
|
||||
</div>
|
||||
<AccordionPrimitive.Trigger>
|
||||
{customTrigger || <MorphingTrigger />}
|
||||
</AccordionPrimitive.Trigger>
|
||||
</div>
|
||||
{subtitle && (
|
||||
<Text as="span" size="small" className="mt-1">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</AccordionPrimitive.Header>
|
||||
<AccordionPrimitive.Content
|
||||
forceMount={forceMountContent}
|
||||
className={clx(
|
||||
"radix-state-closed:animate-accordion-close radix-state-open:animate-accordion-open radix-state-closed:pointer-events-none px-1"
|
||||
)}
|
||||
>
|
||||
<div className="inter-base-regular group-radix-state-closed:animate-accordion-close">
|
||||
{description && <Text>{description}</Text>}
|
||||
<div className="w-full">{children}</div>
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
</AccordionPrimitive.Item>
|
||||
);
|
||||
};
|
||||
|
||||
Accordion.Item = Item;
|
||||
|
||||
const MorphingTrigger = () => {
|
||||
return (
|
||||
<div className="btn-ghost rounded-rounded group relative p-[6px]">
|
||||
<div className="h-5 w-5">
|
||||
<span className="bg-grey-50 rounded-circle group-radix-state-open:rotate-90 absolute inset-y-[31.75%] left-[48%] right-1/2 w-[1.5px] duration-300" />
|
||||
<span className="bg-grey-50 rounded-circle group-radix-state-open:rotate-90 group-radix-state-open:left-1/2 group-radix-state-open:right-1/2 absolute inset-x-[31.75%] top-[48%] bottom-1/2 h-[1.5px] duration-300" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Accordion;
|
||||
@ -0,0 +1,27 @@
|
||||
import { Text, clx } from "@medusajs/ui"
|
||||
|
||||
type CardProps = {
|
||||
icon?: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Card = ({
|
||||
icon,
|
||||
children,
|
||||
className
|
||||
}: CardProps) => {
|
||||
return (
|
||||
<div className={clx(
|
||||
"p-4 rounded-lg gap-3",
|
||||
"flex items-start shadow-elevation-card-rest",
|
||||
"bg-ui-bg-subtle",
|
||||
className
|
||||
)}>
|
||||
{icon}
|
||||
<Text size="base" className="text-ui-fg-subtle">{children}</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Card
|
||||
@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import IconProps from "../../../types/icon-type";
|
||||
|
||||
const ActiveCircleDottedLine: React.FC<IconProps> = ({
|
||||
size = "24",
|
||||
color = "currentColor",
|
||||
...attributes
|
||||
}) => {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg" {...attributes}>
|
||||
<g filter="url(#filter0_d_8860_2802)">
|
||||
<rect x="3" y="3" width="20" height="20" rx="10" fill="white"/>
|
||||
<path d="M15.5 5.93589C13.884 5.3547 12.116 5.3547 10.5 5.93589" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M5.63037 11.632C5.94283 9.94471 6.82561 8.41606 8.13082 7.30209" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M8.13082 18.6979C6.82563 17.5839 5.94286 16.0552 5.63037 14.368" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M10.5 20.0641C12.116 20.6453 13.884 20.6453 15.5 20.0641" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M20.3696 11.632C20.0571 9.94471 19.1744 8.41606 17.8691 7.30209" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M17.8691 18.6979C19.1743 17.5839 20.0571 16.0552 20.3696 14.368" stroke={color} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_8860_2802" x="0" y="0" width="26" height="26" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feMorphology radius="3" operator="dilate" in="SourceAlpha" result="effect1_dropShadow_8860_2802"/>
|
||||
<feOffset/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0.231373 0 0 0 0 0.509804 0 0 0 0 0.964706 0 0 0 0.2 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_8860_2802"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_8860_2802" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default ActiveCircleDottedLine;
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,8 @@
|
||||
import React from "react"
|
||||
|
||||
type IconProps = {
|
||||
color?: string
|
||||
size?: string | number
|
||||
} & React.SVGAttributes<SVGElement>
|
||||
|
||||
export default IconProps
|
||||
@ -0,0 +1,42 @@
|
||||
import { Store } from "@medusajs/medusa"
|
||||
import type Medusa from "@medusajs/medusa-js"
|
||||
import { ExtendedStoreDTO } from "@medusajs/medusa/dist/types/store"
|
||||
|
||||
export default async function prepareRegions (client: Medusa) {
|
||||
let { regions } = await client.admin.regions.list()
|
||||
if (!regions.length) {
|
||||
let { store } = await client.admin.store.retrieve()
|
||||
if (!store.currencies) {
|
||||
store = (await client.admin.store.update({
|
||||
currencies: ["eur"]
|
||||
})).store as ExtendedStoreDTO
|
||||
}
|
||||
|
||||
regions = [(await client.admin.regions.create(getSampleRegion(store))).region]
|
||||
}
|
||||
|
||||
return regions
|
||||
}
|
||||
|
||||
function getSampleRegion (store: Store) {
|
||||
return {
|
||||
name: "EU",
|
||||
currency_code: store.currencies[0].code,
|
||||
tax_rate: 0,
|
||||
payment_providers: [
|
||||
"manual"
|
||||
],
|
||||
fulfillment_providers: [
|
||||
"manual"
|
||||
],
|
||||
countries: [
|
||||
"gb",
|
||||
"de",
|
||||
"dk",
|
||||
"se",
|
||||
"fr",
|
||||
"es",
|
||||
"it"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
import { Region } from "@medusajs/medusa";
|
||||
import type Medusa from "@medusajs/medusa-js"
|
||||
|
||||
export default async function prepareShippingOptions (client: Medusa, region: Region) {
|
||||
let { shipping_options } = await client.admin.shippingOptions.list()
|
||||
if (!shipping_options.length) {
|
||||
shipping_options = [(await client.admin.shippingOptions.create({
|
||||
"name": "PostFake Standard",
|
||||
"region_id": region.id,
|
||||
"provider_id": "manual",
|
||||
"data": {
|
||||
"id": "manual-fulfillment"
|
||||
},
|
||||
"price_type": "flat_rate",
|
||||
"amount": 1000
|
||||
})).shipping_option]
|
||||
}
|
||||
|
||||
return shipping_options
|
||||
}
|
||||
@ -0,0 +1,666 @@
|
||||
import { AdminPostProductsReq, Region } from "@medusajs/medusa"
|
||||
|
||||
type SampleProductsOptions = {
|
||||
regions: Region[]
|
||||
collection_id?: string
|
||||
}
|
||||
|
||||
// can't use the ProductStatus imported
|
||||
// from the core within admin cusotmizations
|
||||
enum ProductStatus {
|
||||
PUBLISHED = "published"
|
||||
}
|
||||
|
||||
export default function getSampleProducts ({
|
||||
regions,
|
||||
collection_id
|
||||
}: SampleProductsOptions): AdminPostProductsReq[] {
|
||||
return [
|
||||
{
|
||||
title: "Medusa T-Shirt",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
collection_id,
|
||||
discountable: true,
|
||||
subtitle: null,
|
||||
description: "Reimagine the feeling of a classic T-shirt. With our cotton T-shirts, everyday essentials no longer have to be ordinary.",
|
||||
handle: "medusa-t-shirt",
|
||||
is_giftcard: false,
|
||||
weight: 400,
|
||||
images: [
|
||||
"https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-black-front.png",
|
||||
"https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-black-back.png",
|
||||
"https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-white-front.png",
|
||||
"https://medusa-public-images.s3.eu-west-1.amazonaws.com/tee-white-back.png"
|
||||
],
|
||||
options: [
|
||||
{
|
||||
title: "Size",
|
||||
},
|
||||
{
|
||||
title: "Color",
|
||||
}
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "S / Black",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 2200
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "S"
|
||||
},
|
||||
{
|
||||
value: "Black"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "S / White",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 2200
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "S"
|
||||
},
|
||||
{
|
||||
value: "White"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "M / Black",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 2200
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "M"
|
||||
},
|
||||
{
|
||||
value: "Black"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "M / White",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 2200
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "M"
|
||||
},
|
||||
{
|
||||
value: "White"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "L / Black",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 2200
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "L"
|
||||
},
|
||||
{
|
||||
value: "Black"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "L / White",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 2200
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "L"
|
||||
},
|
||||
{
|
||||
value: "White"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "XL / Black",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 2200
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "XL"
|
||||
},
|
||||
{
|
||||
value: "Black"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "XL / White",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 2200
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "XL"
|
||||
},
|
||||
{
|
||||
value: "White"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Medusa Sweatshirt",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
discountable: true,
|
||||
collection_id,
|
||||
subtitle: null,
|
||||
description: "Reimagine the feeling of a classic sweatshirt. With our cotton sweatshirt, everyday essentials no longer have to be ordinary.",
|
||||
handle: "sweatshirt",
|
||||
is_giftcard: false,
|
||||
weight: 400,
|
||||
images: [
|
||||
"https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatshirt-vintage-front.png",
|
||||
"https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatshirt-vintage-back.png"
|
||||
],
|
||||
options: [
|
||||
{
|
||||
title: "Size",
|
||||
}
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "S",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 3350
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "S"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "M",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 3350
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "M"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "L",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 3350
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "L"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "XL",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 3350
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "XL"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Medusa Sweatpants",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
discountable: true,
|
||||
collection_id,
|
||||
subtitle: null,
|
||||
description: "Reimagine the feeling of classic sweatpants. With our cotton sweatpants, everyday essentials no longer have to be ordinary.",
|
||||
handle: "sweatpants",
|
||||
is_giftcard: false,
|
||||
weight: 400,
|
||||
images: [
|
||||
"https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-front.png",
|
||||
"https://medusa-public-images.s3.eu-west-1.amazonaws.com/sweatpants-gray-back.png"
|
||||
],
|
||||
options: [
|
||||
{
|
||||
title: "Size",
|
||||
}
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "S",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 3350
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "S"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "M",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 3350
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "M"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "L",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 3350
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "L"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "XL",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 3350
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "XL"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Medusa Shorts",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
discountable: true,
|
||||
collection_id,
|
||||
subtitle: null,
|
||||
description: "Reimagine the feeling of classic shorts. With our cotton shorts, everyday essentials no longer have to be ordinary.",
|
||||
handle: "shorts",
|
||||
is_giftcard: false,
|
||||
weight: 400,
|
||||
images: [
|
||||
"https://medusa-public-images.s3.eu-west-1.amazonaws.com/shorts-vintage-front.png",
|
||||
"https://medusa-public-images.s3.eu-west-1.amazonaws.com/shorts-vintage-back.png"
|
||||
],
|
||||
options: [
|
||||
{
|
||||
title: "Size",
|
||||
}
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "S",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 2850
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "S"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "M",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 2850
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "M"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "L",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 2850
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "L"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "XL",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 2850
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "XL"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Medusa Hoodie",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
discountable: true,
|
||||
collection_id,
|
||||
subtitle: null,
|
||||
description: "Reimagine the feeling of a classic hoodie. With our cotton hoodie, everyday essentials no longer have to be ordinary.",
|
||||
handle: "hoodie",
|
||||
is_giftcard: false,
|
||||
weight: 400,
|
||||
images: [
|
||||
"https://medusa-public-images.s3.eu-west-1.amazonaws.com/black_hoodie_front.png",
|
||||
"https://medusa-public-images.s3.eu-west-1.amazonaws.com/black_hoodie_back.png"
|
||||
],
|
||||
options: [
|
||||
{
|
||||
title: "Size",
|
||||
}
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "S",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 4150
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "S"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "M",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 4150
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "M"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "L",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 4150
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "L"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "XL",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 4150
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "XL"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Medusa Longsleeve",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
discountable: true,
|
||||
collection_id,
|
||||
subtitle: null,
|
||||
description: "Reimagine the feeling of a classic longsleeve. With our cotton longsleeve, everyday essentials no longer have to be ordinary.",
|
||||
handle: "longsleeve",
|
||||
is_giftcard: false,
|
||||
weight: 400,
|
||||
images: [
|
||||
"https://medusa-public-images.s3.eu-west-1.amazonaws.com/ls-black-front.png",
|
||||
"https://medusa-public-images.s3.eu-west-1.amazonaws.com/ls-black-back.png"
|
||||
],
|
||||
options: [
|
||||
{
|
||||
title: "Size",
|
||||
}
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "S",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 4150
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "S"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "M",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 4150
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "M"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "L",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 4150
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "L"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
},
|
||||
{
|
||||
title: "XL",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 4150
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "XL"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Medusa Coffee Mug",
|
||||
status: ProductStatus.PUBLISHED,
|
||||
discountable: true,
|
||||
collection_id,
|
||||
subtitle: null,
|
||||
description: "Every programmer's best friend.",
|
||||
handle: "coffee-mug",
|
||||
is_giftcard: false,
|
||||
weight: 400,
|
||||
images: [
|
||||
"https://medusa-public-images.s3.eu-west-1.amazonaws.com/coffee-mug.png"
|
||||
],
|
||||
options: [
|
||||
{
|
||||
title: "Size",
|
||||
}
|
||||
],
|
||||
variants: [
|
||||
{
|
||||
title: "One Size",
|
||||
prices: regions.map((region) => {
|
||||
return {
|
||||
currency_code: region.currency_code,
|
||||
amount: 1200
|
||||
}
|
||||
}),
|
||||
options: [
|
||||
{
|
||||
value: "One Size"
|
||||
}
|
||||
],
|
||||
inventory_quantity: 100,
|
||||
manage_inventory: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,502 @@
|
||||
import { OrderDetailsWidgetProps, ProductDetailsWidgetProps, WidgetConfig, WidgetProps } from "@medusajs/admin";
|
||||
import { useAdminCustomPost, useAdminCustomQuery, useMedusa } from "medusa-react";
|
||||
import React, { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import { useNavigate, useSearchParams, useLocation } from "react-router-dom";
|
||||
import { OnboardingState } from "../../../models/onboarding";
|
||||
import {
|
||||
AdminOnboardingUpdateStateReq,
|
||||
OnboardingStateRes,
|
||||
UpdateOnboardingStateInput,
|
||||
} from "../../../types/onboarding";
|
||||
import OrderDetailDefault from "../../components/onboarding-flow/default/orders/order-detail";
|
||||
import OrdersListDefault from "../../components/onboarding-flow/default/orders/orders-list";
|
||||
import ProductDetailDefault from "../../components/onboarding-flow/default/products/product-detail";
|
||||
import ProductsListDefault from "../../components/onboarding-flow/default/products/products-list";
|
||||
import { Button, Container, Heading, Text, clx } from "@medusajs/ui";
|
||||
import Accordion from "../../components/shared/accordion";
|
||||
import GetStarted from "../../components/shared/icons/get-started";
|
||||
import { Order, Product } from "@medusajs/medusa";
|
||||
import ProductsListNextjs from "../../components/onboarding-flow/nextjs/products/products-list";
|
||||
import ProductDetailNextjs from "../../components/onboarding-flow/nextjs/products/product-detail";
|
||||
import OrdersListNextjs from "../../components/onboarding-flow/nextjs/orders/orders-list";
|
||||
import OrderDetailNextjs from "../../components/onboarding-flow/nextjs/orders/order-detail";
|
||||
|
||||
type STEP_ID =
|
||||
| "create_product"
|
||||
| "preview_product"
|
||||
| "create_order"
|
||||
| "setup_finished"
|
||||
| "create_product_nextjs"
|
||||
| "preview_product_nextjs"
|
||||
| "create_order_nextjs"
|
||||
| "setup_finished_nextjs"
|
||||
|
||||
type OnboardingWidgetProps = WidgetProps | ProductDetailsWidgetProps | OrderDetailsWidgetProps
|
||||
|
||||
export type StepContentProps = OnboardingWidgetProps & {
|
||||
onNext?: Function;
|
||||
isComplete?: boolean;
|
||||
data?: OnboardingState;
|
||||
};
|
||||
|
||||
type Step = {
|
||||
id: STEP_ID;
|
||||
title: string;
|
||||
component: React.FC<StepContentProps>;
|
||||
onNext?: Function;
|
||||
};
|
||||
|
||||
const QUERY_KEY = ["onboarding_state"];
|
||||
|
||||
const OnboardingFlow = (props: OnboardingWidgetProps) => {
|
||||
// create custom hooks for custom endpoints
|
||||
const { data, isLoading } = useAdminCustomQuery<
|
||||
undefined,
|
||||
OnboardingStateRes
|
||||
>("/onboarding", QUERY_KEY);
|
||||
const { mutate } = useAdminCustomPost<
|
||||
AdminOnboardingUpdateStateReq,
|
||||
OnboardingStateRes
|
||||
>("/onboarding", QUERY_KEY);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
// will be used if onboarding step
|
||||
// is passed as a path parameter
|
||||
const { client } = useMedusa();
|
||||
|
||||
// get current step from custom endpoint
|
||||
const currentStep: STEP_ID | undefined = useMemo(() => {
|
||||
return data?.status
|
||||
?.current_step as STEP_ID
|
||||
}, [data]);
|
||||
|
||||
// initialize some state
|
||||
const [openStep, setOpenStep] = useState(currentStep);
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
// this method is used to move from one step to the next
|
||||
const setStepComplete = ({
|
||||
step_id,
|
||||
extraData,
|
||||
onComplete,
|
||||
}: {
|
||||
step_id: STEP_ID;
|
||||
extraData?: UpdateOnboardingStateInput;
|
||||
onComplete?: () => void;
|
||||
}) => {
|
||||
const next = steps[findStepIndex(step_id) + 1];
|
||||
mutate({ current_step: next.id, ...extraData }, {
|
||||
onSuccess: onComplete
|
||||
});
|
||||
};
|
||||
|
||||
// this is useful if you want to change the current step
|
||||
// using a path parameter. It can only be changed if the passed
|
||||
// step in the path parameter is the next step.
|
||||
const [ searchParams ] = useSearchParams()
|
||||
|
||||
// the steps are set based on the
|
||||
// onboarding type
|
||||
const steps: Step[] = useMemo(() => {
|
||||
{
|
||||
switch(process.env.MEDUSA_ADMIN_ONBOARDING_TYPE) {
|
||||
case 'nextjs':
|
||||
return [
|
||||
{
|
||||
id: "create_product_nextjs",
|
||||
title: "Create Products",
|
||||
component: ProductsListNextjs,
|
||||
onNext: (product: Product) => {
|
||||
setStepComplete({
|
||||
step_id: "create_product_nextjs",
|
||||
extraData: { product_id: product.id },
|
||||
onComplete: () => {
|
||||
if (!location.pathname.startsWith(`/a/products/${product.id}`)) {
|
||||
navigate(`/a/products/${product.id}`)
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "preview_product_nextjs",
|
||||
title: "Preview Product in your Next.js Storefront",
|
||||
component: ProductDetailNextjs,
|
||||
onNext: () => {
|
||||
setStepComplete({
|
||||
step_id: "preview_product_nextjs",
|
||||
onComplete: () => navigate(`/a/orders`),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "create_order_nextjs",
|
||||
title: "Create an Order using your Next.js Storefront",
|
||||
component: OrdersListNextjs,
|
||||
onNext: (order: Order) => {
|
||||
setStepComplete({
|
||||
step_id: "create_order_nextjs",
|
||||
onComplete: () => {
|
||||
if (!location.pathname.startsWith(`/a/orders/${order.id}`)) {
|
||||
navigate(`/a/orders/${order.id}`)
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "setup_finished_nextjs",
|
||||
title: "Setup Finished: Continue Building your Ecommerce Store",
|
||||
component: OrderDetailNextjs,
|
||||
},
|
||||
]
|
||||
default:
|
||||
return [
|
||||
{
|
||||
id: "create_product",
|
||||
title: "Create Product",
|
||||
component: ProductsListDefault,
|
||||
onNext: (product: Product) => {
|
||||
setStepComplete({
|
||||
step_id: "create_product",
|
||||
extraData: { product_id: product.id },
|
||||
onComplete: () => {
|
||||
if (!location.pathname.startsWith(`/a/products/${product.id}`)) {
|
||||
navigate(`/a/products/${product.id}`)
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "preview_product",
|
||||
title: "Preview Product",
|
||||
component: ProductDetailDefault,
|
||||
onNext: () => {
|
||||
setStepComplete({
|
||||
step_id: "preview_product",
|
||||
onComplete: () => navigate(`/a/orders`),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "create_order",
|
||||
title: "Create an Order",
|
||||
component: OrdersListDefault,
|
||||
onNext: (order: Order) => {
|
||||
setStepComplete({
|
||||
step_id: "create_order",
|
||||
onComplete: () => {
|
||||
if (!location.pathname.startsWith(`/a/orders/${order.id}`)) {
|
||||
navigate(`/a/orders/${order.id}`)
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "setup_finished",
|
||||
title: "Setup Finished: Start developing with Medusa",
|
||||
component: OrderDetailDefault,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}, [location.pathname])
|
||||
|
||||
// used to retrieve the index of a step by its ID
|
||||
const findStepIndex = useCallback((step_id: STEP_ID) => {
|
||||
return steps.findIndex((step) => step.id === step_id)
|
||||
}, [steps])
|
||||
|
||||
// used to check if a step is completed
|
||||
const isStepComplete = useCallback((step_id: STEP_ID) => {
|
||||
return findStepIndex(currentStep) > findStepIndex(step_id)
|
||||
}, [findStepIndex, currentStep]);
|
||||
|
||||
// this is used to retrieve the data necessary
|
||||
// to move to the next onboarding step
|
||||
const getOnboardingParamStepData = useCallback(async (onboardingStep: string, data?: {
|
||||
orderId?: string,
|
||||
productId?: string,
|
||||
}) => {
|
||||
switch (onboardingStep) {
|
||||
case "setup_finished_nextjs":
|
||||
case "setup_finished":
|
||||
if (!data?.orderId && "order" in props) {
|
||||
return props.order
|
||||
}
|
||||
const orderId = data?.orderId || searchParams.get("order_id")
|
||||
if (orderId) {
|
||||
return (await client.admin.orders.retrieve(orderId)).order
|
||||
}
|
||||
|
||||
throw new Error ("Required `order_id` parameter was not passed as a parameter")
|
||||
case "preview_product_nextjs":
|
||||
case "preview_product":
|
||||
if (!data?.productId && "product" in props) {
|
||||
return props.product
|
||||
}
|
||||
const productId = data?.productId || searchParams.get("product_id")
|
||||
if (productId) {
|
||||
return (await client.admin.products.retrieve(productId)).product
|
||||
}
|
||||
|
||||
throw new Error ("Required `product_id` parameter was not passed as a parameter")
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}, [searchParams, props])
|
||||
|
||||
const isProductCreateStep = useMemo(() => {
|
||||
return currentStep === "create_product" ||
|
||||
currentStep === "create_product_nextjs"
|
||||
}, [currentStep])
|
||||
|
||||
const isOrderCreateStep = useMemo(() => {
|
||||
return currentStep === "create_order" ||
|
||||
currentStep === "create_order_nextjs"
|
||||
}, [currentStep])
|
||||
|
||||
// used to change the open step when the current
|
||||
// step is retrieved from custom endpoints
|
||||
useEffect(() => {
|
||||
setOpenStep(currentStep);
|
||||
|
||||
if (findStepIndex(currentStep) === steps.length - 1) setCompleted(true);
|
||||
}, [currentStep, findStepIndex]);
|
||||
|
||||
// used to check if the user created a product and has entered its details page
|
||||
// the step is changed to the next one
|
||||
useEffect(() => {
|
||||
if (location.pathname.startsWith("/a/products/prod_") && isProductCreateStep && "product" in props) {
|
||||
// change to the preview product step
|
||||
const currentStepIndex = findStepIndex(currentStep)
|
||||
steps[currentStepIndex].onNext?.(props.product)
|
||||
}
|
||||
}, [location.pathname, isProductCreateStep])
|
||||
|
||||
// used to check if the user created an order and has entered its details page
|
||||
// the step is changed to the next one.
|
||||
useEffect(() => {
|
||||
if (location.pathname.startsWith("/a/orders/order_") && isOrderCreateStep && "order" in props) {
|
||||
// change to the preview product step
|
||||
const currentStepIndex = findStepIndex(currentStep)
|
||||
steps[currentStepIndex].onNext?.(props.order)
|
||||
}
|
||||
}, [location.pathname, isOrderCreateStep])
|
||||
|
||||
// used to check if the `onboarding_step` path
|
||||
// parameter is passed and, if so, moves to that step
|
||||
// only if it's the next step and its necessary data is passed
|
||||
useEffect(() => {
|
||||
const onboardingStep = searchParams.get("onboarding_step") as STEP_ID
|
||||
const onboardingStepIndex = findStepIndex(onboardingStep)
|
||||
if (onboardingStep && onboardingStepIndex !== -1 && onboardingStep !== openStep) {
|
||||
// change current step to the onboarding step
|
||||
const openStepIndex = findStepIndex(openStep)
|
||||
|
||||
if (onboardingStepIndex !== openStepIndex + 1) {
|
||||
// can only go forward one step
|
||||
return
|
||||
}
|
||||
|
||||
// retrieve necessary data and trigger the next function
|
||||
getOnboardingParamStepData(onboardingStep)
|
||||
.then((data) => {
|
||||
steps[openStepIndex].onNext?.(data)
|
||||
})
|
||||
.catch((e) => console.error(e))
|
||||
}
|
||||
}, [searchParams, openStep, getOnboardingParamStepData])
|
||||
|
||||
if (
|
||||
!isLoading &&
|
||||
data?.status?.is_complete &&
|
||||
!localStorage.getItem("override_onboarding_finish")
|
||||
)
|
||||
return null;
|
||||
|
||||
// a method that will be triggered when
|
||||
// the setup is started
|
||||
const onStart = () => {
|
||||
mutate({ current_step: steps[0].id });
|
||||
navigate(`/a/products`);
|
||||
};
|
||||
|
||||
// a method that will be triggered when
|
||||
// the setup is completed
|
||||
const onComplete = () => {
|
||||
setCompleted(true);
|
||||
};
|
||||
|
||||
// a method that will be triggered when
|
||||
// the setup is closed
|
||||
const onHide = () => {
|
||||
mutate({ is_complete: true });
|
||||
};
|
||||
|
||||
// used to get text for get started header
|
||||
const getStartedText = () => {
|
||||
switch(process.env.MEDUSA_ADMIN_ONBOARDING_TYPE) {
|
||||
case "nextjs":
|
||||
return "Learn the basics of Medusa by creating your first order using the Next.js storefront."
|
||||
default:
|
||||
return "Learn the basics of Medusa by creating your first order."
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container className={clx(
|
||||
"text-ui-fg-subtle px-0 pt-0 pb-4",
|
||||
{
|
||||
"mb-4": completed
|
||||
}
|
||||
)}>
|
||||
<Accordion
|
||||
type="single"
|
||||
value={openStep}
|
||||
onValueChange={(value) => setOpenStep(value as STEP_ID)}
|
||||
>
|
||||
<div className={clx(
|
||||
"flex py-6 px-8",
|
||||
{
|
||||
"items-start": completed,
|
||||
"items-center": !completed
|
||||
}
|
||||
)}>
|
||||
<div className="w-12 h-12 p-1 flex justify-center items-center rounded-full bg-ui-bg-base shadow-elevation-card-rest mr-4">
|
||||
<GetStarted />
|
||||
</div>
|
||||
{!completed ? (
|
||||
<>
|
||||
<div>
|
||||
<Heading level="h1" className="text-ui-fg-base">Get started</Heading>
|
||||
<Text>
|
||||
{getStartedText()}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="ml-auto flex items-start gap-2">
|
||||
{!!currentStep ? (
|
||||
<>
|
||||
{currentStep === steps[steps.length - 1].id ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="base"
|
||||
onClick={() => onComplete()}
|
||||
>
|
||||
Complete Setup
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="base"
|
||||
onClick={() => onHide()}
|
||||
>
|
||||
Cancel Setup
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="base"
|
||||
onClick={() => onHide()}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="base"
|
||||
onClick={() => onStart()}
|
||||
>
|
||||
Begin setup
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<Heading level="h1" className="text-ui-fg-base">
|
||||
Thank you for completing the setup guide!
|
||||
</Heading>
|
||||
<Text>
|
||||
This whole experience was built using our new{" "}
|
||||
<strong>widgets</strong> feature.
|
||||
<br /> You can find out more details and build your own by
|
||||
following{" "}
|
||||
<a
|
||||
href="https://docs.medusajs.com/admin/onboarding?ref=onboarding"
|
||||
target="_blank"
|
||||
className="text-blue-500 font-semibold"
|
||||
>
|
||||
our guide
|
||||
</a>
|
||||
.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="ml-auto flex items-start gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="base"
|
||||
onClick={() => onHide()}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{
|
||||
<div>
|
||||
{(!completed ? steps : steps.slice(-1)).map((step) => {
|
||||
const isComplete = isStepComplete(step.id);
|
||||
const isCurrent = currentStep === step.id;
|
||||
return (
|
||||
<Accordion.Item
|
||||
title={step.title}
|
||||
value={step.id}
|
||||
headingSize="medium"
|
||||
active={isCurrent}
|
||||
complete={isComplete}
|
||||
disabled={!isComplete && !isCurrent}
|
||||
key={step.id}
|
||||
{...(!isComplete &&
|
||||
!isCurrent && {
|
||||
customTrigger: <></>,
|
||||
})}
|
||||
>
|
||||
<div className="pl-14 pb-6 pr-7">
|
||||
<step.component
|
||||
onNext={step.onNext}
|
||||
isComplete={isComplete}
|
||||
data={data?.status}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</Accordion>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const config: WidgetConfig = {
|
||||
zone: [
|
||||
"product.list.before",
|
||||
"product.details.before",
|
||||
"order.list.before",
|
||||
"order.details.before",
|
||||
],
|
||||
};
|
||||
|
||||
export default OnboardingFlow;
|
||||
@ -0,0 +1,179 @@
|
||||
# Custom API Routes
|
||||
|
||||
You may define custom API Routes by putting files in the `/api` directory that export functions returning an express router or a collection of express routers.
|
||||
Medusa supports adding custom API Routes using a file based approach. This means that you can add files in the `/api` directory and the files path will be used as the API Route path. For example, if you add a file called `/api/store/custom/route.ts` it will be available on the `/store/custom` API Route.
|
||||
|
||||
```ts
|
||||
import type { MedusaRequest, MedusaResponse } from "@medusajs/medusa";
|
||||
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
res.json({
|
||||
message: "Hello world!",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Supported HTTP methods
|
||||
|
||||
The file based routing supports the following HTTP methods:
|
||||
|
||||
- GET
|
||||
- POST
|
||||
- PUT
|
||||
- PATCH
|
||||
- DELETE
|
||||
- OPTIONS
|
||||
- HEAD
|
||||
|
||||
You can define a handler for each of these methods by exporting a function with the name of the method in the paths `route.ts` file. For example, if you want to define a handler for the `GET`, `POST`, and `PUT` methods, you can do so by exporting functions with the names `GET`, `POST`, and `PUT`:
|
||||
|
||||
```ts
|
||||
import type { MedusaRequest, MedusaResponse } from "@medusajs/medusa";
|
||||
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
// Handle GET requests
|
||||
}
|
||||
|
||||
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
||||
// Handle POST requests
|
||||
}
|
||||
|
||||
export async function PUT(req: MedusaRequest, res: MedusaResponse) {
|
||||
// Handle PUT requests
|
||||
}
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
You can define parameters in the path of your route by using wrapping the parameter name in square brackets. 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,
|
||||
ProductService,
|
||||
} from "@medusajs/medusa";
|
||||
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const { productId } = req.params;
|
||||
|
||||
const productService: ProductService = req.scope.resolve("productService");
|
||||
|
||||
const product = await productService.retrieve(productId);
|
||||
|
||||
res.json({
|
||||
product,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
If you want to define a route that takes multiple parameters, you can do so by adding multiple parameters in the path. It is important that each parameter is given a unique name. 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`. Duplicate parameter names are not allowed, and will result in an error.
|
||||
|
||||
## Using the container
|
||||
|
||||
A global container is available on `req.scope` to allow you to use any of the registered services from the core, installed plugins or your local project:
|
||||
|
||||
```ts
|
||||
import type {
|
||||
MedusaRequest,
|
||||
MedusaResponse,
|
||||
ProductService,
|
||||
} from "@medusajs/medusa";
|
||||
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const productService: ProductService = req.scope.resolve("productService");
|
||||
|
||||
const products = await productService.list();
|
||||
|
||||
res.json({
|
||||
products,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Middleware
|
||||
|
||||
You can apply middleware to your routes by creating a file called `/api/middlewares.ts`. This file should 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 type {
|
||||
MiddlewaresConfig,
|
||||
MedusaRequest,
|
||||
MedusaResponse,
|
||||
MedusaNextFunction,
|
||||
} from "@medusajs/medusa";
|
||||
|
||||
async function logger(
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse,
|
||||
next: MedusaNextFunction
|
||||
) {
|
||||
console.log("Request received");
|
||||
next();
|
||||
}
|
||||
|
||||
export const config: MiddlewaresConfig = {
|
||||
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.
|
||||
|
||||
You might only want to apply middleware to certain HTTP methods. You can do so by adding a `method` property to the route configuration object:
|
||||
|
||||
```ts
|
||||
export const config: MiddlewaresConfig = {
|
||||
routes: [
|
||||
{
|
||||
matcher: "/store/custom",
|
||||
method: "GET",
|
||||
middlewares: [logger],
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
The `method` property can be either a HTTP method or an array of HTTP methods. By default the middlewares will apply to all HTTP methods for the given `matcher`.
|
||||
|
||||
### Default middleware
|
||||
|
||||
Some middleware functions are applied per default:
|
||||
|
||||
#### Global middleware
|
||||
|
||||
JSON parsing is applied to all routes. This means that you can access the request body as `req.body` and it will be parsed as JSON, if the request has a `Content-Type` header of `application/json`.
|
||||
|
||||
If you want to use a different parser for a specific route, such as `urlencoded`, you can do so by adding the following export to your `route.ts` file:
|
||||
|
||||
```ts
|
||||
import { urlencoded } from "express";
|
||||
|
||||
export const config: MiddlewaresConfig = {
|
||||
routes: [
|
||||
{
|
||||
method: "POST",
|
||||
matcher: "/store/custom",
|
||||
middlewares: [urlencoded()],
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
#### Store middleware
|
||||
|
||||
For all `/store` routes, the appropriate CORS settings are applied. The STORE_CORS value can be configured in your `medusa-config.js` file.
|
||||
|
||||
#### Admin middleware
|
||||
|
||||
For all `/admin` routes, the appropriate CORS settings are applied. The ADMIN_CORS value can be configured in your `medusa-config.js` file.
|
||||
|
||||
All `/admin` routes also have admin authentication applied per default. If you want to disable this for a specific route, you can do so by adding the following export to your `route.ts` file:
|
||||
|
||||
```ts
|
||||
export const AUTHENTICATE = false;
|
||||
```
|
||||
@ -0,0 +1,8 @@
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/medusa";
|
||||
|
||||
export async function GET(
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse
|
||||
): Promise<void> {
|
||||
res.sendStatus(200);
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
import type { MedusaRequest, MedusaResponse } from "@medusajs/medusa";
|
||||
import { EntityManager } from "typeorm";
|
||||
|
||||
import OnboardingService from "../../../services/onboarding";
|
||||
|
||||
export async function GET(req: MedusaRequest, res: MedusaResponse) {
|
||||
const onboardingService: OnboardingService =
|
||||
req.scope.resolve("onboardingService");
|
||||
|
||||
const status = await onboardingService.retrieve();
|
||||
|
||||
res.status(200).json({ status });
|
||||
}
|
||||
|
||||
export async function POST(req: MedusaRequest, res: MedusaResponse) {
|
||||
const onboardingService: OnboardingService =
|
||||
req.scope.resolve("onboardingService");
|
||||
const manager: EntityManager = req.scope.resolve("manager");
|
||||
|
||||
const status = await manager.transaction(async (transactionManager) => {
|
||||
return await onboardingService
|
||||
.withTransaction(transactionManager)
|
||||
.update(req.body);
|
||||
});
|
||||
|
||||
res.status(200).json({ status });
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
import { MedusaRequest, MedusaResponse } from "@medusajs/medusa";
|
||||
|
||||
export async function GET(
|
||||
req: MedusaRequest,
|
||||
res: MedusaResponse
|
||||
): Promise<void> {
|
||||
res.sendStatus(200);
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
# Custom scheduled jobs
|
||||
|
||||
You may define custom scheduled jobs (cron jobs) by creating files in the `/jobs` directory.
|
||||
|
||||
```ts
|
||||
import {
|
||||
ProductService,
|
||||
ScheduledJobArgs,
|
||||
ScheduledJobConfig,
|
||||
} from "@medusajs/medusa";
|
||||
|
||||
export default async function myCustomJob({ container }: ScheduledJobArgs) {
|
||||
const productService: ProductService = container.resolve("productService");
|
||||
|
||||
const products = await productService.listAndCount();
|
||||
|
||||
// Do something with the products
|
||||
}
|
||||
|
||||
export const config: ScheduledJobConfig = {
|
||||
name: "daily-product-report",
|
||||
schedule: "0 0 * * *", // Every day at midnight
|
||||
};
|
||||
```
|
||||
|
||||
A scheduled job is defined in two parts a `handler` and a `config`. The `handler` is a function which is invoked when the job is scheduled. The `config` is an object which defines the name of the job, the schedule, and an optional data object.
|
||||
|
||||
The `handler` is a function which takes one parameter, an `object` of type `ScheduledJobArgs` with the following properties:
|
||||
|
||||
- `container` - a `MedusaContainer` instance which can be used to resolve services.
|
||||
- `data` - an `object` containing data passed to the job when it was scheduled. This object is passed in the `config` object.
|
||||
- `pluginOptions` - an `object` containing plugin options, if the job is defined in a plugin.
|
||||
@ -0,0 +1,19 @@
|
||||
# Custom loader
|
||||
|
||||
The loader allows you have access to the Medusa service container. This allows you to access the database and the services registered on the container.
|
||||
you can register custom registrations in the container or run custom code on startup.
|
||||
|
||||
```ts
|
||||
// src/loaders/my-loader.ts
|
||||
|
||||
import { AwilixContainer } from 'awilix'
|
||||
|
||||
/**
|
||||
*
|
||||
* @param container The container in which the registrations are made
|
||||
* @param config The options of the plugin or the entire config object
|
||||
*/
|
||||
export default (container: AwilixContainer, config: Record<string, unknown>): void | Promise<void> => {
|
||||
/* Implement your own loader. */
|
||||
}
|
||||
```
|
||||
@ -0,0 +1,21 @@
|
||||
import { generateEntityId } from "@medusajs/utils";
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CreateOnboarding1685715079776 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "onboarding_state" ("id" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "current_step" character varying NULL, "is_complete" boolean)`
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`INSERT INTO "onboarding_state" ("id", "current_step", "is_complete") VALUES ('${generateEntityId(
|
||||
"",
|
||||
"onboarding"
|
||||
)}' , NULL, false)`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE "onboarding_state"`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddOnboardingProduct1686062614694 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "onboarding_state" ADD COLUMN "product_id" character varying NULL`
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "onboarding_state" DROP COLUMN "product_id"`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class CorrectOnboardingFields1690996567455 implements MigrationInterface {
|
||||
name = 'CorrectOnboardingFields1690996567455'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "onboarding_state" ADD CONSTRAINT "PK_891b72628471aada55d7b8c9410" PRIMARY KEY ("id")`);
|
||||
await queryRunner.query(`ALTER TABLE "onboarding_state" ALTER COLUMN "is_complete" SET NOT NULL`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "onboarding_state" ALTER COLUMN "is_complete" DROP NOT NULL`);
|
||||
await queryRunner.query(`ALTER TABLE "onboarding_state" DROP CONSTRAINT "PK_891b72628471aada55d7b8c9410"`);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
# Custom migrations
|
||||
|
||||
You may define custom models (entities) that will be registered on the global container by creating files in the `src/models` directory that export an instance of `BaseEntity`.
|
||||
In that case you also need to provide a migration in order to create the table in the database.
|
||||
|
||||
## Example
|
||||
|
||||
### 1. Create the migration
|
||||
|
||||
See [How to Create Migrations](https://docs.medusajs.com/advanced/backend/migrations/) in the documentation.
|
||||
|
||||
```ts
|
||||
// src/migration/my-migration.ts
|
||||
|
||||
import { MigrationInterface, QueryRunner } from "typeorm"
|
||||
|
||||
export class MyMigration1617703530229 implements MigrationInterface {
|
||||
name = "myMigration1617703530229"
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// write you migration here
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// write you migration here
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
@ -0,0 +1,14 @@
|
||||
import { BaseEntity } from "@medusajs/medusa";
|
||||
import { Column, Entity } from "typeorm";
|
||||
|
||||
@Entity()
|
||||
export class OnboardingState extends BaseEntity {
|
||||
@Column({ nullable: true })
|
||||
current_step: string;
|
||||
|
||||
@Column()
|
||||
is_complete: boolean;
|
||||
|
||||
@Column({ nullable: true })
|
||||
product_id: string;
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
import { dataSource } from "@medusajs/medusa/dist/loaders/database";
|
||||
import { OnboardingState } from "../models/onboarding";
|
||||
|
||||
const OnboardingRepository = dataSource.getRepository(OnboardingState);
|
||||
|
||||
export default OnboardingRepository;
|
||||
@ -0,0 +1,49 @@
|
||||
# Custom services
|
||||
|
||||
You may define custom services that will be registered on the global container by creating files in the `/services` directory that export an instance of `BaseService`.
|
||||
|
||||
```ts
|
||||
// src/services/my-custom.ts
|
||||
|
||||
import { Lifetime } from "awilix"
|
||||
import { TransactionBaseService } from "@medusajs/medusa";
|
||||
import { IEventBusService } from "@medusajs/types";
|
||||
|
||||
export default class MyCustomService extends TransactionBaseService {
|
||||
static LIFE_TIME = Lifetime.SCOPED
|
||||
protected readonly eventBusService_: IEventBusService
|
||||
|
||||
constructor(
|
||||
{ eventBusService }: { eventBusService: IEventBusService },
|
||||
options: Record<string, unknown>
|
||||
) {
|
||||
// @ts-ignore
|
||||
super(...arguments)
|
||||
|
||||
this.eventBusService_ = eventBusService
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
The first argument to the `constructor` is the global giving you access to easy dependency injection. The container holds all registered services from the core, installed plugins and from other files in the `/services` directory. The registration name is a camelCased version of the file name with the type appended i.e.: `my-custom.js` is registered as `myCustomService`, `custom-thing.js` is registered as `customThingService`.
|
||||
|
||||
You may use the services you define here in custom endpoints by resolving the services defined.
|
||||
|
||||
```js
|
||||
import { Router } from "express"
|
||||
|
||||
export default () => {
|
||||
const router = Router()
|
||||
|
||||
router.get("/hello-product", async (req, res) => {
|
||||
const myService = req.scope.resolve("myCustomService")
|
||||
|
||||
res.json({
|
||||
message: await myService.getProductMessage()
|
||||
})
|
||||
})
|
||||
|
||||
return router;
|
||||
}
|
||||
```
|
||||
@ -0,0 +1,5 @@
|
||||
describe('MyService', () => {
|
||||
it('should do this', async () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,52 @@
|
||||
import { TransactionBaseService } from "@medusajs/medusa";
|
||||
import OnboardingRepository from "../repositories/onboarding";
|
||||
import { OnboardingState } from "../models/onboarding";
|
||||
import { EntityManager, IsNull, Not } from "typeorm";
|
||||
import { UpdateOnboardingStateInput } from "../types/onboarding";
|
||||
|
||||
type InjectedDependencies = {
|
||||
manager: EntityManager;
|
||||
onboardingRepository: typeof OnboardingRepository;
|
||||
};
|
||||
|
||||
class OnboardingService extends TransactionBaseService {
|
||||
protected onboardingRepository_: typeof OnboardingRepository;
|
||||
|
||||
constructor({ onboardingRepository }: InjectedDependencies) {
|
||||
super(arguments[0]);
|
||||
|
||||
this.onboardingRepository_ = onboardingRepository;
|
||||
}
|
||||
|
||||
async retrieve(): Promise<OnboardingState | undefined> {
|
||||
const onboardingRepo = this.activeManager_.withRepository(
|
||||
this.onboardingRepository_
|
||||
);
|
||||
|
||||
const status = await onboardingRepo.findOne({
|
||||
where: { id: Not(IsNull()) },
|
||||
});
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
async update(data: UpdateOnboardingStateInput): Promise<OnboardingState> {
|
||||
return await this.atomicPhase_(
|
||||
async (transactionManager: EntityManager) => {
|
||||
const onboardingRepository = transactionManager.withRepository(
|
||||
this.onboardingRepository_
|
||||
);
|
||||
|
||||
const status = await this.retrieve();
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
status[key] = value;
|
||||
}
|
||||
|
||||
return await onboardingRepository.save(status);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default OnboardingService;
|
||||
@ -0,0 +1,44 @@
|
||||
# Custom subscribers
|
||||
|
||||
You may define custom eventhandlers, `subscribers` by creating files in the `/subscribers` directory.
|
||||
|
||||
```ts
|
||||
import MyCustomService from "../services/my-custom";
|
||||
import {
|
||||
OrderService,
|
||||
SubscriberArgs,
|
||||
SubscriberConfig,
|
||||
} from "@medusajs/medusa";
|
||||
|
||||
type OrderPlacedEvent = {
|
||||
id: string;
|
||||
no_notification: boolean;
|
||||
};
|
||||
|
||||
export default async function orderPlacedHandler({
|
||||
data,
|
||||
eventName,
|
||||
container,
|
||||
}: SubscriberArgs<OrderPlacedEvent>) {
|
||||
const orderService: OrderService = container.resolve(OrderService);
|
||||
|
||||
const order = await orderService.retrieve(data.id, {
|
||||
relations: ["items", "items.variant", "items.variant.product"],
|
||||
});
|
||||
|
||||
// Do something with the order
|
||||
}
|
||||
|
||||
export const config: SubscriberConfig = {
|
||||
event: OrderService.Events.PLACED,
|
||||
};
|
||||
```
|
||||
|
||||
A subscriber is defined in two parts a `handler` and a `config`. The `handler` is a function which is invoked when an event is emitted. The `config` is an object which defines which event(s) the subscriber should subscribe to.
|
||||
|
||||
The `handler` is a function which takes one parameter, an `object` of type `SubscriberArgs<T>` with the following properties:
|
||||
|
||||
- `data` - an `object` of type `T` containing information about the event.
|
||||
- `eventName` - a `string` containing the name of the event.
|
||||
- `container` - a `MedusaContainer` instance which can be used to resolve services.
|
||||
- `pluginOptions` - an `object` containing plugin options, if the subscriber is defined in a plugin.
|
||||
@ -0,0 +1,13 @@
|
||||
import { OnboardingState } from "../models/onboarding";
|
||||
|
||||
export type UpdateOnboardingStateInput = {
|
||||
current_step?: string;
|
||||
is_complete?: boolean;
|
||||
product_id?: string;
|
||||
};
|
||||
|
||||
export interface AdminOnboardingUpdateStateReq {}
|
||||
|
||||
export type OnboardingStateRes = {
|
||||
status: OnboardingState;
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "esnext"
|
||||
},
|
||||
"include": ["src/admin"],
|
||||
"exclude": ["**/*.spec.js"]
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2019",
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"skipLibCheck": true,
|
||||
"skipDefaultLibCheck": true,
|
||||
"declaration": true,
|
||||
"sourceMap": false,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": ".",
|
||||
"jsx": "react-jsx",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"checkJs": false
|
||||
},
|
||||
"include": ["src/"],
|
||||
"exclude": [
|
||||
"**/__tests__",
|
||||
"**/__fixtures__",
|
||||
"node_modules",
|
||||
"build",
|
||||
".cache"
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
/* Emit a single file with source maps instead of having a separate file. */
|
||||
"inlineSourceMap": true
|
||||
},
|
||||
"exclude": ["src/admin", "**/*.spec.js"]
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
Loading…
Reference in New Issue