feat(admin, admin-ui, medusa-js, medusa-react, medusa): Support Admin Extensions (#4761)

Co-authored-by: Rares Stefan <948623+StephixOne@users.noreply.github.com>
Co-authored-by: Oli Juhl <59018053+olivermrbl@users.noreply.github.com>
This commit is contained in:
Kasper Fabricius Kristensen
2023-08-17 12:14:45 +00:00
committed by GitHub
co-authored by Rares Stefan Oli Juhl
parent 26c78bbc03
commit f1a05f4725
189 changed files with 14570 additions and 12773 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ const router = createBrowserRouter(
</>
),
{
basename: __BASE__,
basename: process.env.ADMIN_PATH,
}
)
@@ -10,6 +10,14 @@
font-display: swap;
}
@font-face {
font-family: "Inter";
src: url("../../fonts/Inter-Medium.ttf") format("truetype");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Inter";
src: url("../../fonts/Inter-SemiBold.ttf") format("truetype");
@@ -42,129 +50,129 @@
@layer components {
.inter-5xlarge-regular {
@apply font-sans text-5xlarge leading-4xlarge font-normal;
@apply text-5xlarge leading-4xlarge font-sans font-normal;
}
.inter-5xlarge-semibold {
@apply font-sans text-5xlarge leading-4xlarge font-semibold;
@apply text-5xlarge leading-4xlarge font-sans font-semibold;
}
.inter-4xlarge-regular {
@apply font-sans text-4xlarge leading-3xlarge font-normal;
@apply text-4xlarge leading-3xlarge font-sans font-normal;
}
.inter-4xlarge-semibold {
@apply font-sans text-4xlarge leading-3xlarge font-semibold;
@apply text-4xlarge leading-3xlarge font-sans font-semibold;
}
.inter-3xlarge-regular {
@apply font-sans text-3xlarge leading-2xlarge font-normal;
@apply text-3xlarge leading-2xlarge font-sans font-normal;
}
.inter-3xlarge-semibold {
@apply font-sans text-3xlarge leading-2xlarge font-semibold;
@apply text-3xlarge leading-2xlarge font-sans font-semibold;
}
.inter-2xlarge-regular {
@apply font-sans text-2xlarge leading-xlarge font-normal;
@apply text-2xlarge leading-xlarge font-sans font-normal;
}
.inter-2xlarge-semibold {
@apply font-sans text-2xlarge leading-xlarge font-semibold;
@apply text-2xlarge leading-xlarge font-sans font-semibold;
}
.inter-xlarge-regular {
@apply font-sans text-xlarge leading-large font-normal;
@apply text-xlarge leading-large font-sans font-normal;
}
.inter-xlarge-semibold {
@apply font-sans text-xlarge leading-large font-semibold;
@apply text-xlarge leading-large font-sans font-semibold;
}
.inter-large-regular {
@apply font-sans text-large leading-base font-normal;
@apply text-large leading-base font-sans font-normal;
}
.inter-large-semibold {
@apply font-sans text-large leading-base font-semibold;
@apply text-large leading-base font-sans font-semibold;
}
.inter-base-regular {
@apply font-sans text-base leading-base font-normal;
@apply leading-base font-sans text-base font-normal;
}
.inter-base-semibold {
@apply font-sans text-base leading-base font-semibold;
@apply leading-base font-sans text-base font-semibold;
}
.inter-small-regular {
@apply font-sans text-small leading-small font-normal;
@apply text-small leading-small font-sans font-normal;
}
.inter-small-semibold {
@apply font-sans text-small leading-small font-semibold;
@apply text-small leading-small font-sans font-semibold;
}
.inter-xsmall-regular {
@apply font-sans text-xsmall leading-xsmall font-normal;
@apply text-xsmall leading-xsmall font-sans font-normal;
}
.inter-xsmall-semibold {
@apply font-sans text-xsmall leading-xsmall font-semibold;
@apply text-xsmall leading-xsmall font-sans font-semibold;
}
.mono-5xlarge-regular {
@apply font-mono text-5xlarge leading-4xlarge font-normal;
@apply text-5xlarge leading-4xlarge font-mono font-normal;
}
.mono-5xlarge-semibold {
@apply font-mono text-5xlarge leading-4xlarge font-bold;
@apply text-5xlarge leading-4xlarge font-mono font-bold;
}
.mono-4xlarge-regular {
@apply font-mono text-4xlarge leading-3xlarge font-normal;
@apply text-4xlarge leading-3xlarge font-mono font-normal;
}
.mono-4xlarge-semibold {
@apply font-mono text-4xlarge leading-3xlarge font-bold;
@apply text-4xlarge leading-3xlarge font-mono font-bold;
}
.mono-3xlarge-regular {
@apply font-mono text-3xlarge leading-2xlarge font-normal;
@apply text-3xlarge leading-2xlarge font-mono font-normal;
}
.mono-3xlarge-semibold {
@apply font-mono text-3xlarge leading-2xlarge font-bold;
@apply text-3xlarge leading-2xlarge font-mono font-bold;
}
.mono-2xlarge-regular {
@apply font-mono text-2xlarge leading-xlarge font-normal;
@apply text-2xlarge leading-xlarge font-mono font-normal;
}
.mono-2xlarge-semibold {
@apply font-mono text-2xlarge leading-xlarge font-bold;
@apply text-2xlarge leading-xlarge font-mono font-bold;
}
.mono-xlarge-regular {
@apply font-mono text-xlarge leading-large font-normal;
@apply text-xlarge leading-large font-mono font-normal;
}
.mono-xlarge-semibold {
@apply font-mono text-xlarge leading-large font-bold;
@apply text-xlarge leading-large font-mono font-bold;
}
.mono-large-regular {
@apply font-mono text-large leading-base font-normal;
@apply text-large leading-base font-mono font-normal;
}
.mono-large-semibold {
@apply font-mono text-large leading-base font-bold;
@apply text-large leading-base font-mono font-bold;
}
.mono-base-regular {
@apply font-mono text-base leading-base font-normal;
@apply leading-base font-mono text-base font-normal;
}
.mono-base-semibold {
@apply font-mono text-base leading-base font-bold;
@apply leading-base font-mono text-base font-bold;
}
.mono-small-regular {
@apply font-mono text-small leading-small font-normal;
@apply text-small leading-small font-mono font-normal;
}
.mono-small-semibold {
@apply font-mono text-small leading-small font-bold;
@apply text-small leading-small font-mono font-bold;
}
.mono-xsmall-regular {
@apply font-mono text-xsmall leading-xsmall font-normal;
@apply text-xsmall leading-xsmall font-mono font-normal;
}
.mono-xsmall-semibold {
@apply font-mono text-xsmall leading-xsmall font-bold;
@apply text-xsmall leading-xsmall font-mono font-bold;
}
.radio-outer-ring > span.indicator[data-state="checked"] {
@@ -178,7 +186,7 @@
@layer components {
.react-select-container {
@apply p-0 -mx-3 border-0 mb-1 cursor-text h-6;
@apply -mx-3 mb-1 h-6 cursor-text border-0 p-0;
.react-select__control {
@apply border-0 bg-inherit shadow-none;
@@ -187,17 +195,17 @@
.react-select__control,
.react-select__control--is-focused,
.react-select__control--menu-is-open {
@apply h-6 p-0 m-0 !important;
@apply m-0 h-6 p-0 !important;
}
.react-select__value-container--is-multi,
.react-select__value-container--has-value {
@apply h-6 pl-3 p-0 m-0 !important;
@apply m-0 h-6 p-0 pl-3 !important;
}
.react-select__menu,
.react-select__menu-list {
@apply rounded-t-none mt-0 z-[110] !important;
@apply z-[110] mt-0 rounded-t-none !important;
}
.react-select__value-container {
@@ -205,7 +213,7 @@
}
.react-select__indicators {
@apply p-0 h-full items-center flex pr-3;
@apply flex h-full items-center p-0 pr-3;
.react-select__indicator {
@apply p-0;
@@ -213,7 +221,7 @@
}
.react-select__input {
@apply w-full mt-0 min-w-[120px] pt-0 !important;
@apply mt-0 w-full min-w-[120px] pt-0 !important;
}
.react-select__option,
@@ -231,15 +239,15 @@
@layer components {
.badge {
@apply w-min py-0.5 px-2 rounded-rounded inter-small-semibold;
@apply rounded-rounded inter-small-semibold w-min py-0.5 px-2;
}
.badge-disabled {
@apply bg-grey-50 bg-opacity-10 text-grey-50;
@apply bg-grey-50 text-grey-50 bg-opacity-10;
}
.badge-primary {
@apply bg-violet-60 bg-opacity-10 text-violet-60;
@apply bg-violet-60 text-violet-60 bg-opacity-10;
}
.badge-danger {
@@ -251,11 +259,11 @@
}
.badge-warning {
@apply bg-yellow-40 bg-opacity-20 text-yellow-60;
@apply bg-yellow-40 text-yellow-60 bg-opacity-20;
}
.badge-ghost {
@apply text-grey-90 border border-grey-20 whitespace-nowrap;
@apply text-grey-90 border-grey-20 whitespace-nowrap border;
}
.badge-default {
@@ -263,7 +271,7 @@
}
.btn {
@apply flex items-center justify-center rounded-rounded focus:outline-none focus:shadow-cta;
@apply rounded-rounded focus:shadow-cta flex items-center justify-center focus:outline-none;
}
.btn-large {
@@ -279,23 +287,23 @@
}
.btn-primary {
@apply bg-violet-60 text-grey-0 hover:bg-violet-50 active:bg-violet-70 disabled:bg-grey-20 disabled:text-grey-40;
@apply bg-violet-60 text-grey-0 active:bg-violet-70 disabled:bg-grey-20 disabled:text-grey-40 hover:bg-violet-50;
}
.btn-secondary {
@apply bg-grey-0 text-grey-90 border border-grey-20 hover:bg-grey-5 active:bg-grey-5 active:text-violet-60 focus:border-violet-60 disabled:bg-grey-0 disabled:text-grey-30;
@apply bg-grey-0 text-grey-90 border-grey-20 hover:bg-grey-5 active:bg-grey-5 active:text-violet-60 focus:border-violet-60 disabled:bg-grey-0 disabled:text-grey-30 border;
}
.btn-danger {
@apply bg-grey-0 text-rose-50 border border-grey-20 hover:bg-grey-10 active:bg-grey-20 disabled:bg-grey-0 disabled:text-grey-30;
@apply bg-grey-0 border-grey-20 hover:bg-grey-10 active:bg-grey-20 disabled:bg-grey-0 disabled:text-grey-30 border text-rose-50;
}
.btn-nuclear {
@apply bg-rose-50 text-grey-0 hover:bg-rose-40 active:bg-rose-60 disabled:bg-grey-20 disabled:text-grey-40;
@apply text-grey-0 hover:bg-rose-40 active:bg-rose-60 disabled:bg-grey-20 disabled:text-grey-40 bg-rose-50;
}
.btn-ghost {
@apply bg-transparent text-grey-90 hover:bg-grey-5 active:bg-grey-5 active:text-violet-60 focus:border-violet-60 disabled:bg-transparent disabled:text-grey-30;
@apply text-grey-90 hover:bg-grey-5 active:bg-grey-5 active:text-violet-60 focus:border-violet-60 disabled:text-grey-30 bg-transparent disabled:bg-transparent;
}
.btn-primary-large {
@@ -329,11 +337,11 @@
@layer components {
.date-picker {
@apply border-0 outline-none pt-6 !important;
@apply border-0 pt-6 outline-none !important;
.react-datepicker__month-container {
.react-datepicker__header {
@apply bg-inherit border-0;
@apply border-0 bg-inherit;
}
}
@@ -341,7 +349,7 @@
@apply inter-base-semibold pt-4;
.react-datepicker__day-name {
@apply w-[40px] m-0;
@apply m-0 w-[40px];
}
}
@@ -361,7 +369,7 @@
}
.date {
@apply text-grey-90 m-[0px] w-[38px] h-[38px] align-middle relative leading-none pt-3;
@apply text-grey-90 relative m-[0px] h-[38px] w-[38px] pt-3 align-middle leading-none;
:hover {
@apply cursor-pointer;
}
@@ -396,7 +404,7 @@
}
.vice-city {
@apply bg-gradient-to-tr from-vice-start to-vice-stop;
@apply from-vice-start to-vice-stop bg-gradient-to-tr;
}
.hidden-actions[data-state="open"] {
@@ -426,9 +434,10 @@
@apply bg-grey-40;
}
.accordion-margin-transition {
/* TODO: Fix this as it breaks builds when using preset */
/* .accordion-margin-transition {
@apply transition-[margin] duration-300 ease-[cubic-bezier(0.87,0,0.13,1)];
}
} */
.col-tree:last-child .bottom-half-dash {
@apply border-none;
@@ -26,7 +26,7 @@ const SettingsCard: React.FC<SettingsCardProps> = ({
return (
<Link to={to ?? ""} className="flex flex-1 items-center">
<button
className="bg-grey-0 rounded-rounded p-large border-grey-20 group flex h-full flex-1 items-center border"
className="bg-grey-0 rounded-rounded p-base border-grey-20 group flex h-full flex-1 items-center border"
disabled={disabled}
onClick={() => {
if (externalLink) {
@@ -34,8 +34,10 @@ const SettingsCard: React.FC<SettingsCardProps> = ({
}
}}
>
<div className="h-2xlarge w-2xlarge bg-grey-10 rounded-circle text-grey-60 group-disabled:bg-grey-10 group-disabled:text-grey-40 flex items-center justify-center">
{icon}
<div className="h-2xlarge w-2xlarge bg-grey-0 rounded-rounded border-grey-20 text-grey-60 group-disabled:bg-grey-10 group-disabled:text-grey-40 flex items-center justify-center border">
<div className="bg-grey-10 h-xlarge w-xlarge flex items-center justify-center overflow-hidden rounded-md">
{icon}
</div>
</div>
<div className="mx-large flex-1 text-left">
<h3 className="inter-large-semibold text-grey-90 group-disabled:text-grey-40 m-0">
@@ -0,0 +1,80 @@
import React from "react"
import { Route, Routes } from "react-router-dom"
import { useRoutes } from "../../../providers/route-provider"
import { Route as AdminRoute, RouteSegment } from "../../../types/extensions"
import { isRoute } from "../../../utils/extensions"
import RouteErrorElement from "./route-error-element"
import { useRouteContainerProps } from "./use-route-container-props"
type RouteContainerProps = {
route: AdminRoute | RouteSegment
previousPath?: string
}
const RouteContainer = ({ route, previousPath = "" }: RouteContainerProps) => {
const routeContainerProps = useRouteContainerProps()
const isFullRoute = isRoute(route)
const { path } = route
const { getNestedRoutes } = useRoutes()
const fullPath = `${previousPath}${path}`
const nestedRoutes = getNestedRoutes(fullPath)
const hasNestedRoutes = nestedRoutes.length > 0
/**
* If the route is only a segment, we need to render the nested routes that
* are children of the segment. If the segment has no nested routes, we
* return null.
*/
if (!isFullRoute) {
if (hasNestedRoutes) {
return (
<Routes>
{nestedRoutes.map((r, i) => (
<Route
path={r.path}
key={i}
element={<RouteContainer route={r} previousPath={fullPath} />}
/>
))}
</Routes>
)
}
return null
}
const { Page, origin } = route
const PageWithProps = React.createElement(Page, routeContainerProps)
if (!hasNestedRoutes) {
return PageWithProps
}
return (
<>
<Routes>
<Route
path={"/"}
element={PageWithProps}
errorElement={<RouteErrorElement origin={origin} />}
/>
{nestedRoutes.map((r, i) => (
<Route
path={r.path}
key={i}
element={<RouteContainer route={r} previousPath={fullPath} />}
/>
))}
</Routes>
</>
)
}
export default RouteContainer
@@ -0,0 +1,75 @@
import { useEffect } from "react"
import { useRouteError } from "react-router-dom"
import Button from "../../fundamentals/button"
import RefreshIcon from "../../fundamentals/icons/refresh-icon"
import WarningCircleIcon from "../../fundamentals/icons/warning-circle"
type PageErrorElementProps = {
origin: string
}
const isProd = process.env.NODE_ENV === "production"
const RouteErrorElement = ({ origin }: PageErrorElementProps) => {
const error = useRouteError()
useEffect(() => {
if (!isProd && error) {
console.group(
`%cAn error occurred in a page from ${origin}:`,
"color: red; font-weight: bold;"
)
console.error(error)
console.groupEnd()
}
}, [error, origin])
const reload = () => {
window.location.reload()
}
return (
<div className="flex h-full w-full items-center justify-center">
<div className="rounded-rounded p-base bg-rose-10 border-rose-40 gap-x-small flex justify-start border">
<div>
<WarningCircleIcon
size={20}
fillType="solid"
className="text-rose-40"
/>
</div>
<div className="text-rose-40 inter-small-regular w-full pr-[20px]">
<h1 className="inter-base-semibold mb-2xsmall">Uncaught error</h1>
<p className="mb-small">
{isProd
? "An error unknown error occurred, and the page could not be loaded."
: `A Page from <strong>${origin}</strong> crashed. See the console for more info.`}
</p>
<p className="mb-large">
<strong>What should I do?</strong>
<br />
If you are the developer of this page, you should fix the error and
reload the page. If you are not the developer, you should contact
the maintainer and report the error.
</p>
<div className="gap-x-base flex items-center">
<Button
variant="nuclear"
size="small"
type="button"
onClick={reload}
className="w-full"
>
<div className="flex items-center">
<RefreshIcon size="20" />
<span className="ml-xsmall">Reload</span>
</div>
</Button>
</div>
</div>
</div>
</div>
)
}
export default RouteErrorElement
@@ -0,0 +1,8 @@
import { useExtensionBaseProps } from "../../../hooks/use-extension-base-props"
import { RouteProps } from "../../../types/extensions"
export const useRouteContainerProps = (): RouteProps => {
const baseProps = useExtensionBaseProps()
return baseProps
}
@@ -0,0 +1,14 @@
import React, { ComponentType } from "react"
import { useSettingContainerProps } from "./use-setting-container-props"
type SettingContainerProps = {
Page: ComponentType<any>
}
const SettingContainer = ({ Page }: SettingContainerProps) => {
const props = useSettingContainerProps()
return React.createElement(Page, props)
}
export default SettingContainer
@@ -0,0 +1,77 @@
import { useEffect } from "react"
import { useRouteError } from "react-router-dom"
import Button from "../../fundamentals/button"
import RefreshIcon from "../../fundamentals/icons/refresh-icon"
import WarningCircleIcon from "../../fundamentals/icons/warning-circle"
type SettingsPageErrorElementProps = {
origin: string
}
const isProd = process.env.NODE_ENV === "production"
const SettingsPageErrorElement = ({
origin,
}: SettingsPageErrorElementProps) => {
const error = useRouteError()
useEffect(() => {
if (!isProd && error) {
console.group(
`%cAn error occurred in a settings page from ${origin}:`,
"color: red; font-weight: bold;"
)
console.error(error)
console.groupEnd()
}
}, [error, origin])
const reload = () => {
window.location.reload()
}
return (
<div className="flex h-full w-full items-center justify-center">
<div className="rounded-rounded p-base bg-rose-10 border-rose-40 gap-x-small flex justify-start border">
<div>
<WarningCircleIcon
size={20}
fillType="solid"
className="text-rose-40"
/>
</div>
<div className="text-rose-40 inter-small-regular w-full pr-[20px]">
<h1 className="inter-base-semibold mb-2xsmall">Uncaught error</h1>
<p className="mb-small">
{isProd
? "An error unknown error occurred, and the page could not be loaded."
: `A Page from <strong>${origin}</strong> crashed. See the console for more info.`}
</p>
<p className="mb-large">
<strong>What should I do?</strong>
<br />
If you are the developer of this setting page, you should fix the
error and reload the page. If you are not the developer, you should
contact the maintainer and report the error.
</p>
<div className="gap-x-base flex items-center">
<Button
variant="nuclear"
size="small"
type="button"
onClick={reload}
className="w-full"
>
<div className="flex items-center">
<RefreshIcon size="20" />
<span className="ml-xsmall">Reload</span>
</div>
</Button>
</div>
</div>
</div>
</div>
)
}
export default SettingsPageErrorElement
@@ -0,0 +1,7 @@
import { useExtensionBaseProps } from "../../../hooks/use-extension-base-props"
export const useSettingContainerProps = () => {
const baseProps = useExtensionBaseProps()
return baseProps
}
@@ -0,0 +1,32 @@
import React from "react"
import { InjectionZone, Widget } from "../../../types/extensions"
import { EntityMap } from "./types"
import { useWidgetContainerProps } from "./use-widget-container-props"
import WidgetErrorBoundary from "./widget-error-boundary"
type WidgetContainerProps<T extends keyof EntityMap> = {
injectionZone: T
widget: Widget
entity: EntityMap[T]
}
const WidgetContainer = <T extends InjectionZone>({
injectionZone,
widget,
entity,
}: WidgetContainerProps<T>) => {
const { Widget, origin } = widget
const props = useWidgetContainerProps({
injectionZone,
entity,
})
return (
<WidgetErrorBoundary origin={origin}>
{React.createElement(Widget, props)}
</WidgetErrorBoundary>
)
}
export default WidgetContainer
@@ -0,0 +1,77 @@
import {
Customer,
CustomerGroup,
Discount,
DraftOrder,
GiftCard,
Order,
PriceList,
Product,
ProductCollection,
} from "@medusajs/medusa"
export type EntityMap = {
// Details
"product.details.after": Product
"product.details.before": Product
"product_collection.details.after": ProductCollection
"product_collection.details.before": ProductCollection
"order.details.after": Order
"order.details.before": Order
"draft_order.details.after": DraftOrder
"draft_order.details.before": DraftOrder
"customer.details.after": Customer
"customer.details.before": Customer
"customer_group.details.after": CustomerGroup
"customer_group.details.before": CustomerGroup
"discount.details.after": Discount
"discount.details.before": Discount
"price_list.details.after": PriceList
"price_list.details.before": PriceList
"gift_card.details.after": Product
"gift_card.details.before": Product
"custom_gift_card.after": GiftCard
"custom_gift_card.before": GiftCard
// List
"product.list.after"?: never | null | undefined
"product.list.before"?: never | null | undefined
"product_collection.list.after"?: never | null | undefined
"product_collection.list.before"?: never | null | undefined
"order.list.after"?: never | null | undefined
"order.list.before"?: never | null | undefined
"draft_order.list.after"?: never | null | undefined
"draft_order.list.before"?: never | null | undefined
"customer.list.after"?: never | null | undefined
"customer.list.before"?: never | null | undefined
"customer_group.list.after"?: never | null | undefined
"customer_group.list.before"?: never | null | undefined
"discount.list.after"?: never | null | undefined
"discount.list.before"?: never | null | undefined
"price_list.list.after"?: never | null | undefined
"price_list.list.before"?: never | null | undefined
"gift_card.list.after"?: never | null | undefined
"gift_card.list.before"?: never | null | undefined
// Login
"login.before"?: never | null | undefined
"login.after"?: never | null | undefined
}
export const PropKeyMap = {
"product.details.after": "product",
"product.details.before": "product",
"product_collection.details.after": "productCollection",
"product_collection.details.before": "productCollection",
"order.details.after": "order",
"order.details.before": "order",
"draft_order.details.after": "draftOrder",
"draft_order.details.before": "draftOrder",
"customer.details.after": "customer",
"customer.details.before": "customer",
"customer_group.details.after": "customerGroup",
"customer_group.details.before": "customerGroup",
"discount.details.after": "discount",
"discount.details.before": "discount",
"price_list.details.after": "priceList",
"price_list.details.before": "priceList",
custom_gift_card: "giftCard",
}
@@ -0,0 +1,31 @@
import { useExtensionBaseProps } from "../../../hooks/use-extension-base-props"
import { WidgetProps } from "../../../types/extensions"
import { EntityMap, PropKeyMap } from "./types"
type UseWidgetContainerProps<T extends keyof EntityMap> = {
injectionZone: T
entity?: EntityMap[T]
}
export const useWidgetContainerProps = <T extends keyof EntityMap>({
injectionZone,
entity,
}: UseWidgetContainerProps<T>) => {
const baseProps = useExtensionBaseProps() satisfies WidgetProps
/**
* Not all InjectionZones have an entity, so we need to check for it first, and then
* add it to the props if it exists.
*/
if (entity) {
const propKey = injectionZone as keyof typeof PropKeyMap
const entityKey = PropKeyMap[propKey]
return {
...baseProps,
[entityKey]: entity,
}
}
return baseProps
}
@@ -0,0 +1,136 @@
import React, { ErrorInfo } from "react"
import Button from "../../fundamentals/button"
import RefreshIcon from "../../fundamentals/icons/refresh-icon"
import WarningCircleIcon from "../../fundamentals/icons/warning-circle"
import XCircleIcon from "../../fundamentals/icons/x-circle-icon"
type Props = {
children: React.ReactNode
origin: string
}
type State = {
hasError: boolean
hidden?: boolean
}
class WidgetErrorBoundary extends React.Component<Props, State> {
public state: State = {
hasError: false,
}
public static getDerivedStateFromError(_: Error): State {
return { hasError: true, hidden: false }
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
if (process.env.NODE_ENV !== "production") {
console.group(
`%cAn error occurred in a widget from ${this.props.origin}:`,
"color: red; font-weight: bold, background-color: #fff;"
)
console.error(error)
console.error(
"%cComponent Stack:",
"color: red",
errorInfo.componentStack
)
console.groupEnd()
}
}
public handleResetError() {
this.setState({ hasError: false })
}
public hideError() {
this.setState({ hidden: true })
}
public renderFallback() {
if (process.env.NODE_ENV !== "production" && !this.state.hidden) {
return (
<FallbackWidget
origin={this.props.origin}
reset={this.handleResetError.bind(this)}
hide={this.hideError.bind(this)}
/>
)
}
// Don't render anything in production
return null
}
render() {
if (this.state.hasError) {
return this.renderFallback()
}
return this.props.children
}
}
const FallbackWidget = ({
origin,
reset,
hide,
}: {
origin: string
reset: () => void
hide: () => void
}) => {
return (
<div className="rounded-rounded p-base bg-rose-10 border-rose-40 gap-x-small flex justify-start border">
<div>
<WarningCircleIcon
size={20}
fillType="solid"
className="text-rose-40"
/>
</div>
<div className="text-rose-40 inter-small-regular w-full pr-[20px]">
<h1 className="inter-base-semibold mb-2xsmall">Uncaught error</h1>
<p className="mb-small">
A widget from <strong>{origin}</strong> crashed. See the console for
more info.
</p>
<p className="mb-large">
<strong>What should I do?</strong>
<br />
If you are the developer of this widget, you should fix the error and
reload the page. If you are not the developer, you should contact the
maintainer and report the error.
</p>
<div className="gap-x-base flex items-center">
<Button
variant="nuclear"
size="small"
type="button"
onClick={hide}
className="w-full"
>
<div className="flex items-center">
<XCircleIcon size="20" />
<span className="ml-xsmall">Hide</span>
</div>
</Button>
<Button
variant="nuclear"
size="small"
type="button"
onClick={reset}
className="w-full"
>
<div className="flex items-center">
<RefreshIcon size="20" />
<span className="ml-xsmall">Reload</span>
</div>
</Button>
</div>
</div>
</div>
)
}
export default WidgetErrorBoundary
@@ -0,0 +1,29 @@
import React from "react"
import IconProps from "../types/icon-type"
const ArrowUTurnLeft: React.FC<IconProps> = ({
size = "20",
color = "currentColor",
...attributes
}) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
{...attributes}
>
<path
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7.667 12.333 3.001 7.667m0 0 4.666-4.666M3.001 7.667h9.332a4.666 4.666 0 1 1 0 9.332H10"
/>
</svg>
)
}
export default ArrowUTurnLeft
@@ -10,27 +10,13 @@ const InfoIcon: React.FC<IconProps> = ({
<svg
width={size}
height={size}
viewBox="0 0 16 16"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...attributes}
>
<path
d="M8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 10.6667V8"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 5.33331H8.0075"
d="M8.375 8.375L8.40917 8.35833C8.51602 8.30495 8.63594 8.2833 8.75472 8.29596C8.8735 8.30862 8.98616 8.35505 9.07937 8.42976C9.17258 8.50446 9.24242 8.60432 9.28064 8.71749C9.31885 8.83066 9.32384 8.95242 9.295 9.06833L8.705 11.4317C8.67595 11.5476 8.68078 11.6695 8.71891 11.7828C8.75704 11.8961 8.82687 11.9961 8.92011 12.071C9.01336 12.1458 9.12611 12.1923 9.245 12.205C9.36388 12.2177 9.4839 12.196 9.59083 12.1425L9.625 12.125M16.5 9C16.5 9.98491 16.306 10.9602 15.9291 11.8701C15.5522 12.7801 14.9997 13.6069 14.3033 14.3033C13.6069 14.9997 12.7801 15.5522 11.8701 15.9291C10.9602 16.306 9.98491 16.5 9 16.5C8.01509 16.5 7.03982 16.306 6.12987 15.9291C5.21993 15.5522 4.39314 14.9997 3.6967 14.3033C3.00026 13.6069 2.44781 12.7801 2.0709 11.8701C1.69399 10.9602 1.5 9.98491 1.5 9C1.5 7.01088 2.29018 5.10322 3.6967 3.6967C5.10322 2.29018 7.01088 1.5 9 1.5C10.9891 1.5 12.8968 2.29018 14.3033 3.6967C15.7098 5.10322 16.5 7.01088 16.5 9ZM9 5.875H9.00667V5.88167H9V5.875Z"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
@@ -0,0 +1,29 @@
import React from "react"
import IconProps from "../types/icon-type"
const SquaresPlus: React.FC<IconProps> = ({
size = "20",
color = "currentColor",
...attributes
}) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...attributes}
>
<path
d="M11.25 14.0625H14.0625M14.0625 14.0625H16.875M14.0625 14.0625V11.25M14.0625 14.0625V16.875M5 8.75H6.875C7.37228 8.75 7.84919 8.55246 8.20082 8.20082C8.55246 7.84919 8.75 7.37228 8.75 6.875V5C8.75 4.50272 8.55246 4.02581 8.20082 3.67417C7.84919 3.32254 7.37228 3.125 6.875 3.125H5C4.50272 3.125 4.02581 3.32254 3.67417 3.67417C3.32254 4.02581 3.125 4.50272 3.125 5V6.875C3.125 7.37228 3.32254 7.84919 3.67417 8.20082C4.02581 8.55246 4.50272 8.75 5 8.75V8.75ZM5 16.875H6.875C7.37228 16.875 7.84919 16.6775 8.20082 16.3258C8.55246 15.9742 8.75 15.4973 8.75 15V13.125C8.75 12.6277 8.55246 12.1508 8.20082 11.7992C7.84919 11.4475 7.37228 11.25 6.875 11.25H5C4.50272 11.25 4.02581 11.4475 3.67417 11.7992C3.32254 12.1508 3.125 12.6277 3.125 13.125V15C3.125 15.4973 3.32254 15.9742 3.67417 16.3258C4.02581 16.6775 4.50272 16.875 5 16.875ZM13.125 8.75H15C15.4973 8.75 15.9742 8.55246 16.3258 8.20082C16.6775 7.84919 16.875 7.37228 16.875 6.875V5C16.875 4.50272 16.6775 4.02581 16.3258 3.67417C15.9742 3.32254 15.4973 3.125 15 3.125H13.125C12.6277 3.125 12.1508 3.32254 11.7992 3.67417C11.4475 4.02581 11.25 4.50272 11.25 5V6.875C11.25 7.37228 11.4475 7.84919 11.7992 8.20082C12.1508 8.55246 12.6277 8.75 13.125 8.75V8.75Z"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export default SquaresPlus
@@ -51,7 +51,7 @@ const SigninInput = React.forwardRef(
return (
<div
className={clsx(
"rounded-rounded h-[40px] w-[280px] overflow-hidden border",
"rounded-rounded h-[40px] w-[300px] overflow-hidden border",
"bg-grey-5 inter-base-regular placeholder:text-grey-40",
"focus-within:shadow-input focus-within:border-violet-60",
"flex items-center",
@@ -83,6 +83,7 @@ const SigninInput = React.forwardRef(
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-grey-40 focus:text-violet-60 px-4 focus:outline-none"
tabIndex={-1}
>
{showPassword ? <EyeIcon size={16} /> : <EyeOffIcon size={16} />}
</button>
@@ -3,6 +3,8 @@ import { useEffect } from "react"
import { Controller, useWatch } from "react-hook-form"
import { NestedForm } from "../../../utils/nested-form"
import Switch from "../../atoms/switch"
import InfoIcon from "../../fundamentals/icons/info-icon"
import Tooltip from "../../atoms/tooltip"
export type AnalyticsConfigFormType = {
anonymize: boolean
@@ -11,9 +13,10 @@ export type AnalyticsConfigFormType = {
type Props = {
form: NestedForm<AnalyticsConfigFormType>
compact?: boolean
}
const AnalyticsConfigForm = ({ form }: Props) => {
const AnalyticsConfigForm = ({ form, compact }: Props) => {
const { control, setValue, path } = form
const watchOptOut = useWatch({
@@ -31,17 +34,33 @@ const AnalyticsConfigForm = ({ form }: Props) => {
return (
<div className="gap-y-xlarge flex flex-col">
<div
className={clsx("flex items-start transition-opacity", {
className={clsx("flex items-center gap-3 transition-opacity", {
"opacity-50": watchOptOut,
})}
>
<div className="gap-y-2xsmall flex flex-1 flex-col">
<h2 className="inter-base-semibold">Anonymize my usage data</h2>
<p className="inter-base-regular text-grey-50">
You can choose to anonymize your usage data. If this option is
selected, we will not collect your personal information, such as
your name and email address.
</p>
<div className="flex items-center">
<h2 className="inter-base-semibold mr-2">
Anonymize my usage data{" "}
</h2>
{compact && (
<Tooltip
content="You can choose to anonymize your usage data. If this option is
selected, we will not collect your personal information, such as
your name and email address."
side="top"
>
<InfoIcon size="18px" color={"#889096"} />
</Tooltip>
)}
</div>
{!compact && (
<p className="inter-base-regular text-grey-50">
You can choose to anonymize your usage data. If this option is
selected, we will not collect your personal information, such as
your name and email address.
</p>
)}
</div>
<Controller
name={path("anonymize")}
@@ -57,14 +76,26 @@ const AnalyticsConfigForm = ({ form }: Props) => {
}}
/>
</div>
<div className="flex items-start">
<div className="flex items-center gap-3">
<div className="gap-y-2xsmall flex flex-1 flex-col">
<h2 className="inter-base-semibold">
Opt out of sharing my usage data
</h2>
<p className="inter-base-regular text-grey-50">
You can always opt out of sharing your usage data at any time.
</p>
<div className="flex items-center">
<h2 className="inter-base-semibold mr-2">
Opt out of sharing my usage data
</h2>
{compact && (
<Tooltip
content="You can always opt out of sharing your usage data at any time."
side="top"
>
<InfoIcon size="18px" color={"#889096"} />
</Tooltip>
)}
</div>
{!compact && (
<p className="inter-base-regular text-grey-50">
You can always opt out of sharing your usage data at any time.
</p>
)}
</div>
<Controller
name={path("opt_out")}
@@ -1,7 +1,9 @@
import { useAdminLogin } from "medusa-react"
import { useForm } from "react-hook-form"
import { useNavigate } from "react-router-dom"
import { useWidgets } from "../../../providers/widget-provider"
import InputError from "../../atoms/input-error"
import WidgetContainer from "../../extensions/widget-container"
import Button from "../../fundamentals/button"
import SigninInput from "../../molecules/input-signin"
@@ -24,6 +26,8 @@ const LoginCard = ({ toResetPassword }: LoginCardProps) => {
const navigate = useNavigate()
const { mutate, isLoading } = useAdminLogin()
const { getWidgets } = useWidgets()
const onSubmit = (values: FormValues) => {
mutate(values, {
onSuccess: () => {
@@ -44,44 +48,66 @@ const LoginCard = ({ toResetPassword }: LoginCardProps) => {
})
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col items-center">
<h1 className="inter-xlarge-semibold text-grey-90 mb-large text-[20px]">
Log in to Medusa
</h1>
<div>
<SigninInput
placeholder="Email"
{...register("email", { required: true })}
autoComplete="email"
className="mb-small"
<div className="gap-y-large flex flex-col">
{getWidgets("login.before").map((w, i) => {
return (
<WidgetContainer
key={i}
widget={w}
injectionZone="login.before"
entity={undefined}
/>
<SigninInput
placeholder="Password"
type={"password"}
{...register("password", { required: true })}
autoComplete="current-password"
className="mb-xsmall"
/>
<InputError errors={errors} name="password" />
)
})}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col items-center">
<h1 className="inter-xlarge-semibold text-grey-90 mb-large text-[20px]">
Log in to Medusa
</h1>
<div>
<SigninInput
placeholder="Email"
{...register("email", { required: true })}
autoComplete="email"
className="mb-small"
/>
<SigninInput
placeholder="Password"
type={"password"}
{...register("password", { required: true })}
autoComplete="current-password"
className="mb-xsmall"
/>
<InputError errors={errors} name="password" />
</div>
<Button
className="rounded-rounded inter-base-regular mt-4 w-[280px]"
variant="secondary"
size="medium"
type="submit"
loading={isLoading}
>
Continue
</Button>
<span
className="inter-small-regular text-grey-50 mt-8 cursor-pointer"
onClick={toResetPassword}
>
Forgot your password?
</span>
</div>
<Button
className="rounded-rounded inter-base-regular mt-4 w-[280px]"
variant="secondary"
size="medium"
type="submit"
loading={isLoading}
>
Continue
</Button>
<span
className="inter-small-regular text-grey-50 mt-8 cursor-pointer"
onClick={toResetPassword}
>
Forgot your password?
</span>
</div>
</form>
</form>
{getWidgets("login.after").map((w, i) => {
return (
<WidgetContainer
key={i}
widget={w}
injectionZone="login.after"
entity={undefined}
/>
)
})}
</div>
)
}
@@ -2,14 +2,16 @@ import { useAdminStore } from "medusa-react"
import React, { useState } from "react"
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
import { useRoutes } from "../../../providers/route-provider"
import BuildingsIcon from "../../fundamentals/icons/buildings-icon"
import CartIcon from "../../fundamentals/icons/cart-icon"
import CashIcon from "../../fundamentals/icons/cash-icon"
import GearIcon from "../../fundamentals/icons/gear-icon"
import GiftIcon from "../../fundamentals/icons/gift-icon"
import SaleIcon from "../../fundamentals/icons/sale-icon"
import TagIcon from "../../fundamentals/icons/tag-icon"
import SquaresPlus from "../../fundamentals/icons/squares-plus"
import SwatchIcon from "../../fundamentals/icons/swatch-icon"
import TagIcon from "../../fundamentals/icons/tag-icon"
import UsersIcon from "../../fundamentals/icons/users-icon"
import SidebarMenuItem from "../../molecules/sidebar-menu-item"
import UserMenu from "../../molecules/user-menu"
@@ -22,6 +24,8 @@ const Sidebar: React.FC = () => {
const { isFeatureEnabled } = useFeatureFlag()
const { store } = useAdminStore()
const { getLinks } = useRoutes()
const triggerHandler = () => {
const id = triggerHandler.id++
return {
@@ -104,6 +108,21 @@ const Sidebar: React.FC = () => {
triggerHandler={triggerHandler}
text={"Pricing"}
/>
{getLinks().map(({ path, label, icon }, index) => {
const cleanLink = path.replace("/a/", "")
const Icon = icon ? icon : SquaresPlus
return (
<SidebarMenuItem
key={index}
pageLink={`/a${cleanLink}`}
icon={icon ? <Icon /> : <SquaresPlus size={ICON_SIZE} />}
triggerHandler={triggerHandler}
text={label}
/>
)
})}
<SidebarMenuItem
pageLink={"/a/settings"}
icon={<GearIcon size={ICON_SIZE} />}
@@ -109,7 +109,7 @@ const UserTable: React.FC<UserTableProps> = ({
}
return `${window.location.origin}${
__BASE__ ? `${__BASE__}/` : "/"
process.env.ADMIN_PATH ? `${process.env.ADMIN_PATH}/` : "/"
}invite?token={invite_token}`
}, [store])
@@ -0,0 +1,59 @@
export const forbiddenRoutes = [
"/products",
"/products/:id",
"/product-categories",
"/product-categories",
"/orders",
"/orders/:id",
"/customers",
"/customers/:id",
"/customers/groups",
"/customers/groups/:id",
"/discounts",
"/discounts/new",
"/discounts/:id",
"/gift-cards",
"/gift-cards/:id",
"/gift-cards/manage",
"/pricing",
"/pricing/new",
"/pricing/:id",
"/inventory",
"/collections",
"/collections/:id",
"/draft-orders",
"/draft-orders/:id",
"/login",
"/sales-channels",
"/publishable-api-keys",
"/oauth",
"/oauth/:app_name",
] as const
export const isSettingsRoute = (route: string) => {
return route.startsWith("/settings")
}
export const isForbiddenRoute = (route: any): boolean => {
if (isSettingsRoute(route)) {
if (process.env.NODE_ENV !== "production") {
console.warn(
`The route "${route}" is a settings route. Please register the extension in the "settings" directory instead.`
)
}
return true
}
if (forbiddenRoutes.includes(route)) {
if (process.env.NODE_ENV !== "production") {
console.warn(
`The route "${route}" is a forbidden route. We do not currently support overriding default routes.`
)
}
return true
}
return false
}
@@ -0,0 +1,52 @@
export const injectionZones = [
// Order injection zones
"order.details.before",
"order.details.after",
"order.list.before",
"order.list.after",
// Draft order injection zones
"draft_order.list.before",
"draft_order.list.after",
"draft_order.details.before",
"draft_order.details.after",
// Customer injection zones
"customer.details.before",
"customer.details.after",
"customer.list.before",
"customer.list.after",
// Customer group injection zones
"customer_group.details.before",
"customer_group.details.after",
"customer_group.list.before",
"customer_group.list.after",
// Product injection zones
"product.details.before",
"product.details.after",
"product.list.before",
"product.list.after",
// Product collection injection zones
"product_collection.details.before",
"product_collection.details.after",
"product_collection.list.before",
"product_collection.list.after",
// Price list injection zones
"price_list.details.before",
"price_list.details.after",
"price_list.list.before",
"price_list.list.after",
// Discount injection zones
"discount.details.before",
"discount.details.after",
"discount.list.before",
"discount.list.after",
// Gift card injection zones
"gift_card.details.before",
"gift_card.details.after",
"gift_card.list.before",
"gift_card.list.after",
"custom_gift_card.before",
"custom_gift_card.after",
// Login
"login.before",
"login.after",
] as const
@@ -1,2 +1,2 @@
export const MEDUSA_BACKEND_URL =
__MEDUSA_BACKEND_URL__ || "http://localhost:9000"
process.env.MEDUSA_BACKEND_URL || "http://localhost:9000"
@@ -6,25 +6,30 @@ import {
import { useEffect, useState } from "react"
import { useNavigate, useParams } from "react-router-dom"
import BackButton from "../../../components/atoms/back-button"
import Spacer from "../../../components/atoms/spacer"
import Spinner from "../../../components/atoms/spinner"
import WidgetContainer from "../../../components/extensions/widget-container"
import EditIcon from "../../../components/fundamentals/icons/edit-icon"
import TrashIcon from "../../../components/fundamentals/icons/trash-icon"
import Actionables from "../../../components/molecules/actionables"
import JSONView from "../../../components/molecules/json-view"
import DeletePrompt from "../../../components/organisms/delete-prompt"
import { MetadataField } from "../../../components/organisms/metadata"
import RawJSON from "../../../components/organisms/raw-json"
import Section from "../../../components/organisms/section"
import CollectionModal from "../../../components/templates/collection-modal"
import AddProductsTable from "../../../components/templates/collection-product-table/add-product-table"
import ViewProductsTable from "../../../components/templates/collection-product-table/view-products-table"
import useNotification from "../../../hooks/use-notification"
import { useWidgets } from "../../../providers/widget-provider"
import Medusa from "../../../services/api"
import { getErrorMessage } from "../../../utils/error-messages"
import { getErrorStatus } from "../../../utils/get-error-status"
const CollectionDetails = () => {
const { id } = useParams()
const { collection, isLoading, refetch } = useAdminCollection(id!)
const { collection, isLoading, error, refetch } = useAdminCollection(id!)
const deleteCollection = useAdminDeleteCollection(id!)
const updateCollection = useAdminUpdateCollection(id!)
const [showEdit, setShowEdit] = useState(false)
@@ -105,6 +110,32 @@ const CollectionDetails = () => {
}
}, [collection?.products])
const { getWidgets } = useWidgets()
if (error) {
const errorStatus = getErrorStatus(error)
if (errorStatus) {
// If the product is not found, redirect to the 404 page
if (errorStatus.status === 404) {
navigate("/404")
return null
}
}
// Let the error boundary handle the error
throw error
}
if (isLoading || !collection) {
// temp, perhaps use skeletons?
return (
<div className="flex h-[calc(100vh-64px)] w-full items-center justify-center">
<Spinner variant="secondary" />
</div>
)
}
return (
<>
<div className="flex flex-col">
@@ -113,12 +144,19 @@ const CollectionDetails = () => {
path="/a/products?view=collections"
label="Back to Collections"
/>
<div className="rounded-rounded py-large px-xlarge border-grey-20 bg-grey-0 mb-large border">
{isLoading || !collection ? (
<div className="flex h-12 w-full items-center">
<Spinner variant="secondary" size="large" />
</div>
) : (
<div className="gap-y-xsmall flex flex-col">
{getWidgets("product_collection.details.before").map((w, i) => {
return (
<WidgetContainer
key={i}
entity={collection}
injectionZone="product_collection.details.before"
widget={w}
/>
)
})}
<div className="rounded-rounded py-large px-xlarge border-grey-20 bg-grey-0 border">
<div>
<div>
<div className="flex items-center justify-between">
@@ -155,29 +193,44 @@ const CollectionDetails = () => {
</div>
)}
</div>
)}
</div>
<Section
title="Products"
actions={[
{
label: "Edit Products",
icon: <EditIcon size="20" />,
onClick: () => setShowAddProducts(!showAddProducts),
},
]}
>
<p className="text-grey-50 inter-base-regular mt-xsmall mb-base">
Products in this collection
</p>
{collection && (
<ViewProductsTable
key={updates} // force re-render when collection is updated
collectionId={collection.id}
refetchCollection={refetch}
/>
)}
</Section>
{getWidgets("product_collection.details.after").map((w, i) => {
return (
<WidgetContainer
key={i}
entity={collection}
injectionZone="product_collection.details.after"
widget={w}
/>
)
})}
<RawJSON data={collection} title="Raw collection" />
</div>
<Section
title="Products"
actions={[
{
label: "Edit Products",
icon: <EditIcon size="20" />,
onClick: () => setShowAddProducts(!showAddProducts),
},
]}
>
<p className="text-grey-50 inter-base-regular mt-xsmall mb-base">
To start selling, all you need is a name, price, and image.
</p>
{collection && (
<ViewProductsTable
key={updates} // force re-render when collection is updated
collectionId={collection.id}
refetchCollection={refetch}
/>
)}
</Section>
<Spacer />
</div>
{showEdit && (
<CollectionModal
@@ -1,10 +1,25 @@
import { Route, Routes } from "react-router-dom"
import RouteContainer from "../../components/extensions/route-container"
import { useRoutes } from "../../providers/route-provider"
import CollectionDetails from "./details"
const Collections = () => {
const { getNestedRoutes } = useRoutes()
const nestedRoutes = getNestedRoutes("/collections")
return (
<Routes>
<Route path="/:id" element={<CollectionDetails />} />
{nestedRoutes.map((r, i) => {
return (
<Route
path={r.path}
key={i}
element={<RouteContainer route={r} previousPath={"/collections"} />}
/>
)
})}
</Routes>
)
}
@@ -1,10 +1,11 @@
import { useAdminCustomer } from "medusa-react"
import moment from "moment"
import { useState } from "react"
import { useParams } from "react-router-dom"
import { useNavigate, useParams } from "react-router-dom"
import Avatar from "../../../components/atoms/avatar"
import BackButton from "../../../components/atoms/back-button"
import Spinner from "../../../components/atoms/spinner"
import WidgetContainer from "../../../components/extensions/widget-container"
import EditIcon from "../../../components/fundamentals/icons/edit-icon"
import StatusDot from "../../../components/fundamentals/status-indicator"
import Actionables, {
@@ -14,12 +15,15 @@ import BodyCard from "../../../components/organisms/body-card"
import RawJSON from "../../../components/organisms/raw-json"
import Section from "../../../components/organisms/section"
import CustomerOrdersTable from "../../../components/templates/customer-orders-table"
import { useWidgets } from "../../../providers/widget-provider"
import { getErrorStatus } from "../../../utils/get-error-status"
import EditCustomerModal from "./edit"
const CustomerDetail = () => {
const { id } = useParams()
const navigate = useNavigate()
const { customer, isLoading } = useAdminCustomer(id!)
const { customer, isLoading, error } = useAdminCustomer(id!)
const [showEdit, setShowEdit] = useState(false)
const customerName = () => {
@@ -38,6 +42,31 @@ const CustomerDetail = () => {
},
]
const { getWidgets } = useWidgets()
if (error) {
const errorStatus = getErrorStatus(error)
if (errorStatus) {
// If the product is not found, redirect to the 404 page
if (errorStatus.status === 404) {
navigate("/404")
return null
}
}
// Let the error boundary handle the error
throw error
}
if (isLoading || !customer) {
return (
<div className="flex h-[calc(100vh-64px)] w-full items-center justify-center">
<Spinner variant="secondary" />
</div>
)
}
return (
<div>
<BackButton
@@ -46,6 +75,17 @@ const CustomerDetail = () => {
className="mb-xsmall"
/>
<div className="gap-y-xsmall flex flex-col">
{getWidgets("customer.details.before").map((w, i) => {
return (
<WidgetContainer
key={i}
entity={customer}
injectionZone="customer.details.before"
widget={w}
/>
)
})}
<Section>
<div className="flex w-full items-start justify-between">
<div className="gap-x-base flex w-full items-center">
@@ -61,7 +101,7 @@ const CustomerDetail = () => {
{customerName()}
</h1>
<h3 className="inter-small-regular text-grey-50">
{customer?.email}
{customer.email}
</h3>
</div>
</div>
@@ -72,21 +112,21 @@ const CustomerDetail = () => {
<div className="inter-smaller-regular text-grey-50 mb-1">
First seen
</div>
<div>{moment(customer?.created_at).format("DD MMM YYYY")}</div>
<div>{moment(customer.created_at).format("DD MMM YYYY")}</div>
</div>
<div className="flex flex-col pl-6">
<div className="inter-smaller-regular text-grey-50 mb-1">
Phone
</div>
<div className="max-w-[200px] truncate">
{customer?.phone || "N/A"}
{customer.phone || "N/A"}
</div>
</div>
<div className="flex flex-col pl-6">
<div className="inter-smaller-regular text-grey-50 mb-1">
Orders
</div>
<div>{customer?.orders.length}</div>
<div>{customer.orders.length}</div>
</div>
<div className="h-100 flex flex-col pl-6">
<div className="inter-smaller-regular text-grey-50 mb-1">
@@ -94,28 +134,33 @@ const CustomerDetail = () => {
</div>
<div className="h-50 flex items-center justify-center">
<StatusDot
variant={customer?.has_account ? "success" : "danger"}
title={customer?.has_account ? "Registered" : "Guest"}
variant={customer.has_account ? "success" : "danger"}
title={customer.has_account ? "Registered" : "Guest"}
/>
</div>
</div>
</div>
</Section>
<BodyCard
title={`Orders (${customer?.orders.length})`}
title={`Orders (${customer.orders.length})`}
subtitle="An overview of Customer Orders"
>
{isLoading || !customer ? (
<div className="pt-2xlarge flex w-full items-center justify-center">
<Spinner size={"large"} variant={"secondary"} />
</div>
) : (
<div className="flex grow flex-col">
<CustomerOrdersTable id={customer.id} />
</div>
)}
<div className="flex grow flex-col">
<CustomerOrdersTable id={customer.id} />
</div>
</BodyCard>
{getWidgets("customer.details.after").map((w, i) => {
return (
<WidgetContainer
key={i}
entity={customer}
injectionZone="customer.details.after"
widget={w}
/>
)
})}
<RawJSON data={customer} title="Raw customer" />
</div>
@@ -11,6 +11,8 @@ import { useEffect, useState } from "react"
import { useNavigate, useParams } from "react-router-dom"
import BackButton from "../../../components/atoms/back-button"
import Spinner from "../../../components/atoms/spinner"
import WidgetContainer from "../../../components/extensions/widget-container"
import EditIcon from "../../../components/fundamentals/icons/edit-icon"
import PlusIcon from "../../../components/fundamentals/icons/plus-icon"
import TrashIcon from "../../../components/fundamentals/icons/trash-icon"
@@ -21,6 +23,8 @@ import CustomersListTable from "../../../components/templates/customer-group-tab
import EditCustomersTable from "../../../components/templates/customer-group-table/edit-customers-table"
import useQueryFilters from "../../../hooks/use-query-filters"
import useToggleState from "../../../hooks/use-toggle-state"
import { useWidgets } from "../../../providers/widget-provider"
import { getErrorStatus } from "../../../utils/get-error-status"
import CustomerGroupModal from "./customer-group-modal"
/**
@@ -127,7 +131,7 @@ function CustomerGroupCustomersList(props: CustomerGroupCustomersListProps) {
<BodyCard
title="Customers"
actionables={actions}
className="my-4 min-h-[756px] w-full"
className="min-h-[756px] w-full"
>
{showCustomersModal && (
<EditCustomersTable
@@ -229,11 +233,32 @@ function CustomerGroupDetailsHeader(props: CustomerGroupDetailsHeaderProps) {
*/
function CustomerGroupDetails() {
const { id } = useParams()
const navigate = useNavigate()
const { customer_group } = useAdminCustomerGroup(id!)
const { customer_group, isLoading, error } = useAdminCustomerGroup(id!)
const { getWidgets } = useWidgets()
if (!customer_group) {
return null
if (error) {
const errorStatus = getErrorStatus(error)
if (errorStatus) {
// If the product is not found, redirect to the 404 page
if (errorStatus.status === 404) {
navigate("/404")
return null
}
}
// Let the error boundary handle the error
throw error
}
if (isLoading || !customer_group) {
return (
<div className="flex h-[calc(100vh-64px)] w-full items-center justify-center">
<Spinner variant="secondary" />
</div>
)
}
return (
@@ -243,8 +268,32 @@ function CustomerGroupDetails() {
label="Back to customer groups"
className="mb-4"
/>
<CustomerGroupDetailsHeader customerGroup={customer_group} />
<CustomerGroupCustomersList group={customer_group} />
<div className="gap-y-xsmall flex flex-col">
{getWidgets("customer_group.details.before").map((w, i) => {
return (
<WidgetContainer
key={i}
entity={customer_group}
injectionZone="customer_group.details.before"
widget={w}
/>
)
})}
<CustomerGroupDetailsHeader customerGroup={customer_group} />
{getWidgets("customer_group.details.after").map((w, i) => {
return (
<WidgetContainer
key={i}
entity={customer_group}
injectionZone="customer_group.details.after"
widget={w}
/>
)
})}
<CustomerGroupCustomersList group={customer_group} />
</div>
</div>
)
}
@@ -1,8 +1,12 @@
import { Route, Routes } from "react-router-dom"
import RouteContainer from "../../../components/extensions/route-container"
import WidgetContainer from "../../../components/extensions/widget-container"
import PlusIcon from "../../../components/fundamentals/icons/plus-icon"
import BodyCard from "../../../components/organisms/body-card"
import CustomerGroupsTable from "../../../components/templates/customer-group-table/customer-groups-table"
import useToggleState from "../../../hooks/use-toggle-state"
import { useRoutes } from "../../../providers/route-provider"
import { useWidgets } from "../../../providers/widget-provider"
import CustomersPageTableHeader from "../header"
import CustomerGroupModal from "./customer-group-modal"
import Details from "./details"
@@ -12,6 +16,7 @@ import Details from "./details"
*/
function Index() {
const { state, open, close } = useToggleState()
const { getWidgets } = useWidgets()
const actions = [
{
@@ -27,7 +32,18 @@ function Index() {
return (
<>
<div className="flex h-full grow flex-col">
<div className="gap-y-xsmall flex h-full grow flex-col">
{getWidgets("customer_group.list.before").map((w, index) => {
return (
<WidgetContainer
key={index}
entity={null}
widget={w}
injectionZone="customer_group.list.before"
/>
)
})}
<BodyCard
actionables={actions}
className="h-auto"
@@ -35,6 +51,17 @@ function Index() {
>
<CustomerGroupsTable />
</BodyCard>
{getWidgets("customer_group.list.after").map((w, index) => {
return (
<WidgetContainer
key={index}
entity={null}
widget={w}
injectionZone="customer_group.list.after"
/>
)
})}
</div>
<CustomerGroupModal open={state} onClose={close} />
</>
@@ -45,10 +72,25 @@ function Index() {
* Customer groups routes
*/
function CustomerGroups() {
const { getNestedRoutes } = useRoutes()
const nestedRoutes = getNestedRoutes("/customers/groups")
return (
<Routes>
<Route index element={<Index />} />
<Route path="/:id" element={<Details />} />
{nestedRoutes.map((r, i) => {
return (
<Route
path={r.path}
key={i}
element={
<RouteContainer route={r} previousPath={"/customers/groups"} />
}
/>
)
})}
</Routes>
)
}
@@ -1,31 +1,72 @@
import { Route, Routes } from "react-router-dom"
import Spacer from "../../components/atoms/spacer"
import RouteContainer from "../../components/extensions/route-container"
import WidgetContainer from "../../components/extensions/widget-container"
import BodyCard from "../../components/organisms/body-card"
import CustomerTable from "../../components/templates/customer-table"
import { useRoutes } from "../../providers/route-provider"
import { useWidgets } from "../../providers/widget-provider"
import Details from "./details"
import CustomerGroups from "./groups"
import CustomersPageTableHeader from "./header"
const CustomerIndex = () => {
const { getWidgets } = useWidgets()
return (
<div>
<div className="gap-y-xsmall flex flex-col">
{getWidgets("customer.list.before").map((w, index) => {
return (
<WidgetContainer
key={index}
entity={null}
widget={w}
injectionZone="customer.list.before"
/>
)
})}
<BodyCard
customHeader={<CustomersPageTableHeader activeView="customers" />}
className="h-fit"
>
<CustomerTable />
</BodyCard>
{getWidgets("customer.list.after").map((w, index) => {
return (
<WidgetContainer
key={index}
entity={null}
widget={w}
injectionZone="customer.list.after"
/>
)
})}
<Spacer />
</div>
)
}
const Customers = () => {
const { getNestedRoutes } = useRoutes()
const nestedRoutes = getNestedRoutes("/customers")
return (
<Routes>
<Route index element={<CustomerIndex />} />
<Route path="/groups/*" element={<CustomerGroups />} />
<Route path="/:id" element={<Details />} />
{nestedRoutes.map((r, i) => {
return (
<Route
path={r.path}
key={i}
element={<RouteContainer route={r} previousPath={"/customers"} />}
/>
)
})}
</Routes>
)
}
@@ -1,12 +1,15 @@
import { useAdminDeleteDiscount, useAdminDiscount } from "medusa-react"
import { useState } from "react"
import { useParams } from "react-router-dom"
import { useNavigate, useParams } from "react-router-dom"
import BackButton from "../../../components/atoms/back-button"
import Spinner from "../../../components/atoms/spinner"
import WidgetContainer from "../../../components/extensions/widget-container"
import DeletePrompt from "../../../components/organisms/delete-prompt"
import RawJSON from "../../../components/organisms/raw-json"
import useNotification from "../../../hooks/use-notification"
import { useWidgets } from "../../../providers/widget-provider"
import { getErrorMessage } from "../../../utils/error-messages"
import { getErrorStatus } from "../../../utils/get-error-status"
import { DiscountFormProvider } from "../new/discount-form/form/discount-form-context"
import DiscountDetailsConditions from "./conditions"
import Configurations from "./configurations"
@@ -14,8 +17,9 @@ import General from "./general"
const Edit = () => {
const { id } = useParams()
const navigate = useNavigate()
const { discount, isLoading } = useAdminDiscount(
const { discount, isLoading, error } = useAdminDiscount(
id!,
{ expand: "rule,rule.conditions" },
{
@@ -26,6 +30,8 @@ const Edit = () => {
const deleteDiscount = useAdminDeleteDiscount(id!)
const notification = useNotification()
const { getWidgets } = useWidgets()
const handleDelete = () => {
deleteDiscount.mutate(undefined, {
onSuccess: () => {
@@ -37,6 +43,29 @@ const Edit = () => {
})
}
if (error) {
const errorStatus = getErrorStatus(error)
if (errorStatus) {
// If the discount is not found, redirect to the 404 page
if (errorStatus.status === 404) {
navigate("/404")
return null
}
}
// Let the error boundary handle the error
throw error
}
if (isLoading || !discount) {
return (
<div className="flex h-[calc(100vh-64px)] w-full items-center justify-center">
<Spinner variant="secondary" />
</div>
)
}
return (
<div className="pb-xlarge">
{showDelete && (
@@ -55,20 +84,34 @@ const Edit = () => {
path="/a/discounts"
className="mb-xsmall"
/>
{isLoading || !discount ? (
<div className="flex h-full items-center justify-center">
<Spinner variant="secondary" />
</div>
) : (
<div className="gap-y-xsmall flex flex-col">
<DiscountFormProvider>
<General discount={discount} />
<Configurations discount={discount} />
<DiscountDetailsConditions discount={discount} />
<RawJSON data={discount} title="Raw discount" />
</DiscountFormProvider>
</div>
)}
<div className="gap-y-xsmall flex flex-col">
<DiscountFormProvider>
{getWidgets("discount.details.before").map((w, index) => {
return (
<WidgetContainer
key={index}
entity={discount}
widget={w}
injectionZone="discount.details.before"
/>
)
})}
<General discount={discount} />
<Configurations discount={discount} />
<DiscountDetailsConditions discount={discount} />
{getWidgets("discount.details.after").map((w, index) => {
return (
<WidgetContainer
key={index}
entity={discount}
widget={w}
injectionZone="discount.details.after"
/>
)
})}
<RawJSON data={discount} title="Raw discount" />
</DiscountFormProvider>
</div>
</div>
)
}
@@ -2,10 +2,14 @@ import { useState } from "react"
import { Route, Routes } from "react-router-dom"
import Fade from "../../components/atoms/fade-wrapper"
import Spacer from "../../components/atoms/spacer"
import RouteContainer from "../../components/extensions/route-container"
import WidgetContainer from "../../components/extensions/widget-container"
import PlusIcon from "../../components/fundamentals/icons/plus-icon"
import BodyCard from "../../components/organisms/body-card"
import TableViewHeader from "../../components/organisms/custom-table-header"
import DiscountTable from "../../components/templates/discount-table"
import { useRoutes } from "../../providers/route-provider"
import { useWidgets } from "../../providers/widget-provider"
import Details from "./details"
import New from "./new"
import DiscountForm from "./new/discount-form"
@@ -22,9 +26,21 @@ const DiscountIndex = () => {
},
]
const { getWidgets } = useWidgets()
return (
<div className="flex h-full flex-col">
<div className="flex w-full grow flex-col">
<div className="gap-y-xsmall flex w-full grow flex-col">
{getWidgets("discount.list.before").map((w, index) => {
return (
<WidgetContainer
key={index}
widget={w}
injectionZone="discount.list.before"
entity={null}
/>
)
})}
<BodyCard
actionables={actionables}
customHeader={<TableViewHeader views={["discounts"]} />}
@@ -32,6 +48,16 @@ const DiscountIndex = () => {
>
<DiscountTable />
</BodyCard>
{getWidgets("discount.list.after").map((w, index) => {
return (
<WidgetContainer
key={index}
widget={w}
injectionZone="discount.list.after"
entity={null}
/>
)
})}
<Spacer />
</div>
<DiscountFormProvider>
@@ -44,11 +70,24 @@ const DiscountIndex = () => {
}
const Discounts = () => {
const { getNestedRoutes } = useRoutes()
const nestedRoutes = getNestedRoutes("/discounts")
return (
<Routes>
<Route index element={<DiscountIndex />} />
<Route path="/new" element={<New />} />
<Route path="/:id" element={<Details />} />
{nestedRoutes.map((r, i) => {
return (
<Route
path={r.path}
key={i}
element={<RouteContainer route={r} previousPath={"/discounts"} />}
/>
)
})}
</Routes>
)
}
@@ -3,6 +3,7 @@ import moment from "moment"
import { useParams } from "react-router-dom"
import BackButton from "../../../components/atoms/back-button"
import Spinner from "../../../components/atoms/spinner"
import WidgetContainer from "../../../components/extensions/widget-container"
import DollarSignIcon from "../../../components/fundamentals/icons/dollar-sign-icon"
import EditIcon from "../../../components/fundamentals/icons/edit-icon"
import StatusSelector from "../../../components/molecules/status-selector"
@@ -10,6 +11,7 @@ import BodyCard from "../../../components/organisms/body-card"
import RawJSON from "../../../components/organisms/raw-json"
import useNotification from "../../../hooks/use-notification"
import useToggleState from "../../../hooks/use-toggle-state"
import { useWidgets } from "../../../providers/widget-provider"
import { getErrorMessage } from "../../../utils/error-messages"
import { formatAmountWithSymbol } from "../../../utils/prices"
import EditGiftCardModal from "./edit-gift-card-modal"
@@ -24,6 +26,8 @@ const GiftCardDetails = () => {
const updateGiftCard = useAdminUpdateGiftCard(giftCard?.id!)
const { getWidgets } = useWidgets()
const notification = useNotification()
const {
@@ -81,6 +85,17 @@ const GiftCardDetails = () => {
) : (
<>
<div className="gap-y-xsmall flex flex-col">
{getWidgets("custom_gift_card.before").map((w, i) => {
return (
<WidgetContainer
key={i}
widget={w}
entity={giftCard}
injectionZone="custom_gift_card.before"
/>
)
})}
<BodyCard
className={"h-auto min-h-0 w-full"}
title={`${giftCard?.code}`}
@@ -147,6 +162,18 @@ const GiftCardDetails = () => {
</div>
</div>
</BodyCard>
{getWidgets("custom_gift_card.after").map((w, i) => {
return (
<WidgetContainer
key={i}
widget={w}
entity={giftCard}
injectionZone="custom_gift_card.after"
/>
)
})}
<RawJSON data={giftCard} title="Raw gift card" />
</div>
@@ -1,14 +1,29 @@
import { Route, Routes } from "react-router-dom"
import RouteContainer from "../../components/extensions/route-container"
import { useRoutes } from "../../providers/route-provider"
import GiftCardDetails from "./details"
import ManageGiftCard from "./manage"
import Overview from "./overview"
const GiftCard = () => {
const { getNestedRoutes } = useRoutes()
const nestedRoutes = getNestedRoutes("/gift-cards")
return (
<Routes>
<Route path="/" element={<Overview />} />
<Route path="/:id" element={<GiftCardDetails />} />
<Route path="/manage" element={<ManageGiftCard />} />
{nestedRoutes.map((r, i) => {
return (
<Route
path={r.path}
key={i}
element={<RouteContainer route={r} previousPath={"/gift-cards"} />}
/>
)
})}
</Routes>
)
}
@@ -3,12 +3,14 @@ import { useAdminProducts } from "medusa-react"
import { useNavigate } from "react-router-dom"
import BackButton from "../../../components/atoms/back-button"
import Spinner from "../../../components/atoms/spinner"
import WidgetContainer from "../../../components/extensions/widget-container"
import GiftCardDenominationsSection from "../../../components/organisms/gift-card-denominations-section"
import ProductAttributesSection from "../../../components/organisms/product-attributes-section"
import ProductGeneralSection from "../../../components/organisms/product-general-section"
import ProductMediaSection from "../../../components/organisms/product-media-section"
import ProductRawSection from "../../../components/organisms/product-raw-section"
import ProductThumbnailSection from "../../../components/organisms/product-thumbnail-section"
import { useWidgets } from "../../../providers/widget-provider"
import { getErrorStatus } from "../../../utils/get-error-status"
const Manage = () => {
@@ -25,6 +27,8 @@ const Manage = () => {
const giftCard = products?.[0] as Product | undefined
const { getWidgets } = useWidgets()
if (!giftCard) {
return (
<div className="flex h-screen w-full items-center justify-center">
@@ -57,9 +61,34 @@ const Manage = () => {
/>
<div className="gap-x-base grid grid-cols-12">
<div className="gap-y-xsmall col-span-8 flex flex-col">
{getWidgets("gift_card.details.before").map((w, i) => {
return (
<WidgetContainer
key={i}
widget={w}
injectionZone={"gift_card.details.before"}
entity={giftCard}
/>
)
})}
<ProductGeneralSection product={giftCard} />
<GiftCardDenominationsSection giftCard={giftCard} />
<ProductAttributesSection product={giftCard} />
{getWidgets("gift_card.details.after").map((w, i) => {
return (
<WidgetContainer
key={i}
widget={w}
injectionZone={"gift_card.details.after"}
entity={giftCard}
/>
)
})}
<ProductRawSection product={giftCard} />
</div>
<div className="gap-y-xsmall col-span-4 flex flex-col">
@@ -10,6 +10,7 @@ import { useNavigate } from "react-router-dom"
import PageDescription from "../../components/atoms/page-description"
import Spacer from "../../components/atoms/spacer"
import Spinner from "../../components/atoms/spinner"
import WidgetContainer from "../../components/extensions/widget-container"
import PlusIcon from "../../components/fundamentals/icons/plus-icon"
import BannerCard from "../../components/molecules/banner-card"
import BodyCard from "../../components/organisms/body-card"
@@ -18,6 +19,7 @@ import GiftCardBanner from "../../components/organisms/gift-card-banner"
import GiftCardTable from "../../components/templates/gift-card-table"
import useNotification from "../../hooks/use-notification"
import useToggleState from "../../hooks/use-toggle-state"
import { useWidgets } from "../../providers/widget-provider"
import { ProductStatus } from "../../types/shared"
import { getErrorMessage } from "../../utils/error-messages"
import CustomGiftcard from "./custom-giftcard"
@@ -94,6 +96,8 @@ const Overview = () => {
}
}, [giftCard, store])
const { getWidgets } = useWidgets()
return (
<>
<div className="flex flex-col">
@@ -103,6 +107,16 @@ const Overview = () => {
/>
{!isLoading ? (
<div className="gap-y-xsmall flex flex-col">
{getWidgets("gift_card.list.before").map((w, i) => {
return (
<WidgetContainer
key={i}
widget={w}
injectionZone="gift_card.list.before"
entity={null}
/>
)
})}
{giftCardWithCurrency ? (
<GiftCardBanner
{...giftCardWithCurrency}
@@ -130,6 +144,17 @@ const Overview = () => {
>
<GiftCardTable />
</BodyCard>
{getWidgets("gift_card.list.after").map((w, i) => {
return (
<WidgetContainer
key={i}
widget={w}
injectionZone="gift_card.list.after"
entity={null}
/>
)
})}
</div>
) : (
<div className="rounded-rounded border-grey-20 flex h-44 w-full items-center justify-center border">
@@ -1,59 +0,0 @@
import { Order } from "@medusajs/medusa"
import { renderHook, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { useForm, UseFormReturn } from "react-hook-form"
import ClaimTypeForm from ".."
import { fixtures } from "../../../../../../test/fixtures"
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
import { nestedForm } from "../../../../../utils/nested-form"
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
import { getDefaultClaimValues } from "../../../details/utils/get-default-values"
describe("ClaimTypeForm", () => {
let form: UseFormReturn<CreateClaimFormType, any>
beforeEach(() => {
const order = fixtures.get("order") as unknown as Order
const { result } = renderHook(() =>
useForm<CreateClaimFormType>({
defaultValues: getDefaultClaimValues(order),
})
)
form = result.current
renderWithProviders(<ClaimTypeForm form={nestedForm(form, "claim_type")} />)
})
it("should render correctly with the initial value of refund", async () => {
const {
claim_type: { type },
} = form.getValues()
expect(screen.getByText("Refund")).toBeInTheDocument()
expect(screen.getByText("Replace")).toBeInTheDocument()
expect(type).toEqual("refund")
})
it("should update the value of the form when a new type is selected", async () => {
const {
claim_type: { type: initialType },
} = form.getValues()
const user = userEvent.setup()
expect(initialType).toEqual("refund")
const replace = screen.getByLabelText("Replace")
await user.click(replace)
const {
claim_type: { type },
} = form.getValues()
expect(type).toEqual("replace")
})
})
@@ -1,59 +0,0 @@
import { Order, Return } from "@medusajs/medusa"
import { renderHook, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { useForm, UseFormReturn } from "react-hook-form"
import { fixtures } from "../../../../../../test/fixtures"
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
import { nestedForm } from "../../../../../utils/nested-form"
import { ReceiveReturnFormType } from "../../../details/receive-return"
import { getDefaultReceiveReturnValues } from "../../../details/utils/get-default-values"
import { ItemsToReceiveForm } from "../items-to-receive-form"
describe("ItemsToReceiveForm with ReceiveReturnMenu", () => {
let form: UseFormReturn<ReceiveReturnFormType, any>
beforeEach(() => {
const order = fixtures.get("order") as unknown as Order
const return_ = fixtures.get("return") as unknown as Return
const { result } = renderHook(() =>
useForm<ReceiveReturnFormType>({
defaultValues: getDefaultReceiveReturnValues(order, return_),
})
)
form = result.current
renderWithProviders(
<ItemsToReceiveForm
form={nestedForm(form, "receive_items")}
order={order}
/>
)
})
it("should render correctly", async () => {
expect(screen.getByText("Items to receive")).toBeInTheDocument()
expect(screen.getByText("Medusa Shorts")).toBeInTheDocument()
expect(screen.getByText("S")).toBeInTheDocument()
expect(screen.getByText("1")).toBeInTheDocument()
})
it("should mark an item as to be received when checkbox is checked", async () => {
const checkboxes = screen.getAllByRole("checkbox")
const user = userEvent.setup()
// We expect two checkboxes, one for the header and one for the item
expect(checkboxes).toHaveLength(2)
// Item checkbox
const checkbox = checkboxes[1]
expect(checkbox).not.toBeChecked()
await user.click(checkbox)
const { receive_items } = form.getValues()
expect(checkbox).toBeChecked()
expect(receive_items.items[0].receive).toEqual(true)
})
})
@@ -1,119 +0,0 @@
import { Order } from "@medusajs/medusa"
import { renderHook, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { useForm, UseFormReturn } from "react-hook-form"
import ItemsToReturnForm from ".."
import { fixtures } from "../../../../../../test/fixtures"
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
import { nestedForm } from "../../../../../utils/nested-form"
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
import { getDefaultClaimValues } from "../../../details/utils/get-default-values"
const order = fixtures.get("order") as unknown as Order
describe("ItemsToSendForm with RegisterClaimMenu", () => {
let form: UseFormReturn<CreateClaimFormType, any>
beforeEach(() => {
const { result } = renderHook(() =>
useForm<CreateClaimFormType>({
defaultValues: getDefaultClaimValues(order),
})
)
form = result.current
renderWithProviders(
<ItemsToReturnForm
form={nestedForm(form, "return_items")}
order={order}
/>
)
})
it("should render correctly", async () => {
const titles = order.returnable_items?.map((item) => item.title)
// expect all titles in titles array to appear at least once in the document
titles?.forEach((title) => {
expect(screen.getAllByText(title).length).toBeGreaterThan(0)
})
})
it("should initially not display any items as marked for return", async () => {
const checkboxes = screen.getAllByRole("checkbox")
checkboxes.forEach((checkbox) => {
expect(checkbox).not.toBeChecked()
})
})
it("should mark all item as to be returned when checkbox is checked", async () => {
const checkboxes = screen.getAllByRole("checkbox")
// Checkbox to select all items
const checkbox = checkboxes[0]
const user = userEvent.setup()
await user.click(checkbox)
expect(checkbox).toBeChecked()
const { return_items } = form.getValues()
// expect all items to be marked for return
for (const item of return_items.items) {
expect(item.return).toBeTruthy()
}
})
it("should only mark the first item as to be returned", async () => {
const checkboxes = screen.getAllByRole("checkbox")
// Checkbox to select first item
const checkbox = checkboxes[1]
const user = userEvent.setup()
await user.click(checkbox)
expect(checkbox).toBeChecked()
const { return_items } = form.getValues()
// expect first item to be marked for return
expect(return_items.items[0].return).toBeTruthy()
// expect all other items to not be marked for return
for (const item of return_items.items.slice(1)) {
expect(item.return).toBeFalsy()
}
})
it("should update quantity correctly", async () => {
const checkboxes = screen.getAllByRole("checkbox")
const checkbox = checkboxes[1]
const user = userEvent.setup()
await user.click(checkbox)
expect(checkbox).toBeChecked()
const decrement = screen.getByLabelText("Decrease quantity")
await user.click(decrement)
const { return_items } = form.getValues()
expect(return_items.items[0].quantity).toEqual(1)
const increment = screen.getByLabelText("Increase quantity")
await user.click(increment)
// should return to initial quantity
expect(return_items.items[0].quantity).toEqual(2)
})
})
@@ -1,80 +0,0 @@
import { Order } from "@medusajs/medusa"
import { renderHook, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { useForm, UseFormReturn } from "react-hook-form"
import ItemsToSendForm from ".."
import { fixtures } from "../../../../../../test/fixtures"
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
import { nestedForm } from "../../../../../utils/nested-form"
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
import { getDefaultClaimValues } from "../../../details/utils/get-default-values"
const order = fixtures.get("order") as unknown as Order
describe("ItemsToSendForm with RegisterClaimMenu", () => {
let form: UseFormReturn<CreateClaimFormType, any>
beforeEach(() => {
const { result } = renderHook(() =>
useForm<CreateClaimFormType>({
defaultValues: getDefaultClaimValues(order),
})
)
form = result.current
form.setValue("additional_items.items", [
{
in_stock: 100,
original_price: 10000,
price: 10000,
product_title: "Test",
quantity: 1,
variant_id: "test",
variant_title: "Test",
},
])
renderWithProviders(
<ItemsToSendForm
form={nestedForm(form, "additional_items")}
order={order}
/>
)
})
it("should render correctly", async () => {
expect(screen.getByText("Items to send")).toBeInTheDocument()
expect(screen.getByText("Add products")).toBeInTheDocument()
})
it("should display products to send correctly", async () => {
expect(screen.getByText("Test")).toBeInTheDocument()
expect(screen.getByText("€100.00")).toBeInTheDocument()
expect(screen.getByText("1")).toBeInTheDocument()
})
it("should update quantity correctly", async () => {
const { additional_items } = form.getValues()
const user = userEvent.setup()
const increment = screen.getByLabelText("Increase quantity")
await user.click(increment)
expect(screen.getByText("2")).toBeInTheDocument()
expect(additional_items.items[0].quantity).toEqual(2)
await user.click(increment)
expect(screen.getByText("3")).toBeInTheDocument()
expect(additional_items.items[0].quantity).toEqual(3)
const decrement = screen.getByLabelText("Decrease quantity")
await user.click(decrement)
expect(screen.getByText("2")).toBeInTheDocument()
expect(additional_items.items[0].quantity).toEqual(2)
})
})
@@ -1,59 +0,0 @@
import { Order } from "@medusajs/medusa"
import { fireEvent, renderHook, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { useForm, UseFormReturn } from "react-hook-form"
import RefundAmountForm from ".."
import { fixtures } from "../../../../../../test/fixtures"
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
import { nestedForm } from "../../../../../utils/nested-form"
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
import { getDefaultClaimValues } from "../../../details/utils/get-default-values"
describe("RefundAmountForm refund claim", () => {
let form: UseFormReturn<CreateClaimFormType, any>
beforeEach(() => {
const order = fixtures.get("order") as unknown as Order
const { result } = renderHook(() =>
useForm<CreateClaimFormType>({
defaultValues: getDefaultClaimValues(order),
})
)
form = result.current
renderWithProviders(
<RefundAmountForm
form={nestedForm(form, "refund_amount")}
order={order}
/>
)
})
it("should render correctly", async () => {
// Initial value should be 0
expect(screen.getByText("€0.00")).toBeInTheDocument()
})
it("should update value when input is changed", async () => {
const button = screen.getByLabelText("Edit refund amount")
const user = userEvent.setup()
await user.click(button)
const input = screen.getByPlaceholderText("-")
fireEvent.change(input, { target: { value: "100" } })
await waitFor(() => {
const {
refund_amount: { amount },
} = form.getValues()
// We enter 100, but the value should be 10000 since we are transforming from dollars to cents
expect(amount).toEqual(10000)
})
})
})
@@ -1,72 +0,0 @@
import { Order, ShippingOption } from "@medusajs/medusa"
import { renderHook, screen } from "@testing-library/react"
import { useForm } from "react-hook-form"
import { fixtures } from "../../../../../../test/fixtures"
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
import { ClaimSummary } from "../claim-summary"
describe("ClaimSummary", () => {
let order: Order
let so: ShippingOption
beforeEach(() => {
order = fixtures.get("order") as unknown as Order
so = fixtures.get("shipping_option") as unknown as ShippingOption
const { result } = renderHook(() =>
useForm<CreateClaimFormType>({
defaultValues: {
return_items: {
items: fixtures.get("order").items.map((item) => ({
item_id: item.id,
quantity: item.quantity,
return: true,
refundable: 90000,
total: 90000,
original_quantity: item.quantity,
})),
},
additional_items: {
items: fixtures.list("line_item", 5).map((item) => ({
item_id: item.id,
quantity: item.quantity,
price: 10000,
})),
},
replacement_shipping: {
option: {
label: so.name,
value: {
id: so.id,
taxRate: 0,
},
},
},
return_shipping: {
option: {
label: so.name,
value: {
id: so.id,
taxRate: 0,
},
},
},
claim_type: {
type: "replace",
},
},
})
)
renderWithProviders(<ClaimSummary order={order} form={result.current} />)
})
it("should render both a return and replacement shipping option", async () => {
expect(screen.getAllByText(so.name)).toHaveLength(2)
expect(screen.getByText("Return shipping")).toBeInTheDocument()
expect(screen.getByText("Replacement shipping")).toBeInTheDocument()
expect(screen.getAllByText("Free")).toHaveLength(2)
})
})
@@ -1,52 +0,0 @@
import { renderHook, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { useForm, UseFormReturn } from "react-hook-form"
import SendNotificationForm from ".."
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
import { nestedForm } from "../../../../../utils/nested-form"
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
describe("SendNotificationForm", () => {
let form: UseFormReturn<CreateClaimFormType, any>
beforeEach(() => {
const { result } = renderHook(() =>
useForm<CreateClaimFormType>({
defaultValues: {
notification: {
send_notification: true,
},
},
})
)
form = result.current
renderWithProviders(
<SendNotificationForm
type="claim"
form={nestedForm(form, "notification")}
/>
)
})
it("should render initial value correctly", async () => {
const checkbox = screen.getByRole("checkbox")
expect(checkbox).toBeChecked()
})
it("should update the value when the checkbox is clicked", async () => {
const checkbox = screen.getByRole("checkbox")
const user = userEvent.setup()
await user.click(checkbox)
const {
notification: { send_notification },
} = form.getValues()
expect(send_notification).toEqual(false)
expect(checkbox).not.toBeChecked()
})
})
@@ -1,53 +0,0 @@
import { Order } from "@medusajs/medusa"
import { renderHook, screen, waitFor } from "@testing-library/react"
import { useForm, UseFormReturn } from "react-hook-form"
import ShippingAddressForm from ".."
import { fixtures } from "../../../../../../test/fixtures"
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
import { nestedForm } from "../../../../../utils/nested-form"
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
import { getDefaultClaimValues } from "../../../details/utils/get-default-values"
describe("ShippingAddressForm with RegisterClaimMenu", () => {
let form: UseFormReturn<CreateClaimFormType, any>
beforeEach(() => {
const order = fixtures.get("order") as unknown as Order
const { result } = renderHook(() =>
useForm<CreateClaimFormType>({
defaultValues: getDefaultClaimValues(order),
})
)
form = result.current
renderWithProviders(
<ShippingAddressForm
form={nestedForm(form, "shipping_address")}
order={order}
/>
)
})
it("should render the initial address correctly", async () => {
expect(screen.getByText("Shipping address")).toBeInTheDocument()
expect(screen.getByText("Faker Street 1, 3 Floor")).toBeInTheDocument()
expect(screen.getByText("Medusa JS, 2100 Copenhagen")).toBeInTheDocument()
expect(screen.getByText("Denmark")).toBeInTheDocument()
})
it("should render the address correctly when the address is changed", async () => {
await waitFor(() => {
form.setValue("shipping_address.address_1", "123 Second St")
form.setValue("shipping_address.address_2", "Apt 2")
})
const {
shipping_address: { address_1, address_2 },
} = form.getValues()
expect(address_1).toEqual("123 Second St")
expect(address_2).toEqual("Apt 2")
})
})
@@ -1,148 +0,0 @@
import { Order, ShippingOption } from "@medusajs/medusa"
import { renderHook, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup"
import { useForm, UseFormReturn } from "react-hook-form"
import ShippingForm from ".."
import { fixtures } from "../../../../../../test/fixtures"
import { renderWithProviders } from "../../../../../../test/utils/render-with-providers"
import { nestedForm } from "../../../../../utils/nested-form"
import { CreateClaimFormType } from "../../../details/claim/register-claim-menu"
import { getDefaultClaimValues } from "../../../details/utils/get-default-values"
const selectFirstOption = async (user: UserEvent) => {
const combobox = screen.getByRole("combobox")
await waitFor(() => {
combobox.focus()
})
// Open dropdown
await user.keyboard("{arrowdown}")
// Go to first option and select
await user.keyboard("{arrowdown}")
await user.keyboard("{Enter}")
}
describe("ShippingForm return shipping", () => {
let form: UseFormReturn<CreateClaimFormType, any>
beforeEach(() => {
const order = fixtures.get("order") as unknown as Order
const { result } = renderHook(() =>
useForm<CreateClaimFormType>({
defaultValues: getDefaultClaimValues(order),
})
)
form = result.current
renderWithProviders(
<div>
<ShippingForm
order={order}
isClaim
isReturn
form={nestedForm(form, "return_shipping")}
/>
</div>
)
})
it("should render correctly when type is refund", async () => {
expect(screen.getByText("Shipping for return items"))
expect(screen.queryByText("Shipping for replacement items")).toBeNull()
})
it("should render options when dropdown is opened", async () => {
const user = userEvent.setup()
const combobox = screen.getByRole("combobox")
await waitFor(() => {
combobox.focus()
})
await user.keyboard("{arrowdown}")
await waitFor(() => {
expect(screen.getAllByText("Free Shipping")).toHaveLength(5)
})
})
it("should select an option when clicked", async () => {
const user = userEvent.setup()
await selectFirstOption(user)
await waitFor(() => {
expect(screen.getAllByText("Free Shipping")).toHaveLength(1)
})
const { return_shipping } = form.getValues()
expect(return_shipping.option?.label).toEqual("Free Shipping")
expect(return_shipping.option?.value).toEqual(
expect.objectContaining({
id: expect.any(String),
taxRate: 0,
})
)
})
it("should render correctly when option is selected", async () => {
const shippingOption = fixtures.get(
"shipping_option"
) as unknown as ShippingOption
await waitFor(() => {
form.setValue("return_shipping.option", {
label: shippingOption.name,
value: {
id: shippingOption.id,
taxRate: 0.12,
},
})
})
await waitFor(() => {
expect(screen.getByText(shippingOption.name)).toBeInTheDocument()
})
})
})
describe("ShippingForm return shipping", () => {
let form: UseFormReturn<CreateClaimFormType, any>
beforeEach(() => {
const order = fixtures.get("order") as unknown as Order
const { result } = renderHook(() =>
useForm<CreateClaimFormType>({
defaultValues: {
...getDefaultClaimValues(order),
claim_type: {
type: "replace",
},
},
})
)
form = result.current
renderWithProviders(
<div>
<ShippingForm
order={order}
isClaim
form={nestedForm(form, "replacement_shipping")}
/>
</div>
)
})
it("should render correctly when type is replace", async () => {
expect(screen.getByText("Shipping for replacement items"))
expect(screen.queryByText("Shipping for return items")).toBeNull()
})
})
@@ -3,8 +3,8 @@ import {
Order,
VariantInventory,
} from "@medusajs/medusa"
import { DisplayTotal, PaymentDetails } from "../templates"
import React, { useContext, useMemo } from "react"
import { DisplayTotal, PaymentDetails } from "../templates"
import { ActionType } from "../../../../components/molecules/actionables"
import Badge from "../../../../components/fundamentals/badge"
@@ -15,11 +15,11 @@ import OrderLine from "../order-line"
import { ReservationItemDTO } from "@medusajs/types"
import ReserveItemsModal from "../reservation/reserve-items-modal"
import { Response } from "@medusajs/medusa-js"
import StatusIndicator from "../../../../components/fundamentals/status-indicator"
import { sum } from "lodash"
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
import { useMedusa } from "medusa-react"
import StatusIndicator from "../../../../components/fundamentals/status-indicator"
import useToggleState from "../../../../hooks/use-toggle-state"
import { useFeatureFlag } from "../../../../providers/feature-flag-provider"
type SummaryCardProps = {
order: Order
@@ -168,7 +168,7 @@ const SummaryCard: React.FC<SummaryCardProps> = ({ order, reservations }) => {
return (
<BodyCard
className={"mb-4 h-auto min-h-0 w-full"}
className={"h-auto min-h-0 w-full"}
title="Summary"
status={
isFeatureEnabled("inventoryService") &&
@@ -31,8 +31,10 @@ import { useEffect, useMemo, useState } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import Avatar from "../../../components/atoms/avatar"
import BackButton from "../../../components/atoms/back-button"
import Spacer from "../../../components/atoms/spacer"
import Spinner from "../../../components/atoms/spinner"
import Tooltip from "../../../components/atoms/tooltip"
import WidgetContainer from "../../../components/extensions/widget-container"
import Button from "../../../components/fundamentals/button"
import DetailsIcon from "../../../components/fundamentals/details-icon"
import CancelIcon from "../../../components/fundamentals/icons/cancel-icon"
@@ -54,6 +56,7 @@ import useImperativeDialog from "../../../hooks/use-imperative-dialog"
import useNotification from "../../../hooks/use-notification"
import useToggleState from "../../../hooks/use-toggle-state"
import { useFeatureFlag } from "../../../providers/feature-flag-provider"
import { useWidgets } from "../../../providers/widget-provider"
import { isoAlpha2Countries } from "../../../utils/countries"
import { getErrorMessage } from "../../../utils/error-messages"
import extractCustomerName from "../../../utils/extract-customer-name"
@@ -196,6 +199,8 @@ const OrderDetails = () => {
useHotkeys("esc", () => navigate("/a/orders"))
useHotkeys("command+i", handleCopy)
const { getWidgets } = useWidgets()
const handleDeleteOrder = async () => {
const shouldDelete = await dialog({
heading: "Cancel order",
@@ -296,10 +301,22 @@ const OrderDetails = () => {
</BodyCard>
) : (
<>
<div>
{getWidgets("order.details.before").map((widget, i) => {
return (
<WidgetContainer
key={i}
injectionZone={"order.details.before"}
widget={widget}
entity={order}
/>
)
})}
</div>
<div className="flex space-x-4">
<div className="flex h-full w-7/12 flex-col">
<div className="gap-y-base flex h-full w-7/12 flex-col">
<BodyCard
className={"mb-4 min-h-[200px] w-full"}
className={"min-h-[200px] w-full"}
customHeader={
<Tooltip side="top" content={"Copy ID"}>
<button
@@ -359,7 +376,7 @@ const OrderDetails = () => {
<SummaryCard order={order} reservations={reservations || []} />
<BodyCard
className={"mb-4 h-auto min-h-0 w-full"}
className={"h-auto min-h-0 w-full"}
title="Payment"
status={
<PaymentStatusComponent status={order.payment_status} />
@@ -428,7 +445,7 @@ const OrderDetails = () => {
</div>
</BodyCard>
<BodyCard
className={"mb-4 h-auto min-h-0 w-full"}
className={"h-auto min-h-0 w-full"}
title="Fulfillment"
status={
<FulfillmentStatusComponent
@@ -475,7 +492,7 @@ const OrderDetails = () => {
</div>
</BodyCard>
<BodyCard
className={"mb-4 h-auto min-h-0 w-full"}
className={"h-auto min-h-0 w-full"}
title="Customer"
actionables={customerActionables}
>
@@ -525,9 +542,20 @@ const OrderDetails = () => {
</div>
</div>
</BodyCard>
<div className="mt-large">
<RawJSON data={order} title="Raw order" />
<div>
{getWidgets("order.details.after").map((widget, i) => {
return (
<WidgetContainer
key={i}
injectionZone={"order.details.after"}
widget={widget}
entity={order}
/>
)
})}
</div>
<RawJSON data={order} title="Raw order" />
<Spacer />
</div>
<Timeline orderId={order.id} />
</div>
@@ -13,6 +13,7 @@ import Avatar from "../../../components/atoms/avatar"
import BackButton from "../../../components/atoms/back-button"
import CopyToClipboard from "../../../components/atoms/copy-to-clipboard"
import Spinner from "../../../components/atoms/spinner"
import WidgetContainer from "../../../components/extensions/widget-container"
import Button from "../../../components/fundamentals/button"
import DetailsIcon from "../../../components/fundamentals/details-icon"
import DollarSignIcon from "../../../components/fundamentals/icons/dollar-sign-icon"
@@ -24,6 +25,7 @@ import ConfirmationPrompt from "../../../components/organisms/confirmation-promp
import DeletePrompt from "../../../components/organisms/delete-prompt"
import { AddressType } from "../../../components/templates/address-form"
import useNotification from "../../../hooks/use-notification"
import { useWidgets } from "../../../providers/widget-provider"
import { isoAlpha2Countries } from "../../../utils/countries"
import { getErrorMessage } from "../../../utils/error-messages"
import extractCustomerName from "../../../utils/extract-customer-name"
@@ -119,6 +121,11 @@ const DraftOrderDetails = () => {
})
}
const { getWidgets } = useWidgets()
const afterWidgets = getWidgets("draft_order.details.after")
const beforeWidgets = getWidgets("draft_order.details.before")
const { cart } = draft_order || {}
const { region } = cart || {}
@@ -136,6 +143,21 @@ const DraftOrderDetails = () => {
) : (
<div className="flex space-x-4">
<div className="flex h-full w-full flex-col">
{beforeWidgets?.length > 0 && (
<div className="mb-4 flex w-full flex-col gap-y-4">
{beforeWidgets.map((w, i) => {
return (
<WidgetContainer
key={i}
widget={w}
injectionZone="draft_order.details.before"
entity={draft_order}
/>
)
})}
</div>
)}
<BodyCard
className={"mb-4 min-h-[200px] w-full"}
title={`Order #${draft_order.display_id}`}
@@ -359,6 +381,20 @@ const DraftOrderDetails = () => {
</div>
</div>
</BodyCard>
{afterWidgets?.length > 0 && (
<div className="mb-4 flex w-full flex-col gap-y-4">
{afterWidgets.map((w, i) => {
return (
<WidgetContainer
key={i}
widget={w}
injectionZone="draft_order.details.after"
entity={draft_order}
/>
)
})}
</div>
)}
<BodyCard
className={"mb-4 h-auto min-h-0 w-full pt-[15px]"}
title="Raw Draft Order"
@@ -1,11 +1,13 @@
import { useMemo, useState } from "react"
import { Route, Routes, useNavigate } from "react-router-dom"
import Spacer from "../../../components/atoms/spacer"
import WidgetContainer from "../../../components/extensions/widget-container"
import PlusIcon from "../../../components/fundamentals/icons/plus-icon"
import BodyCard from "../../../components/organisms/body-card"
import TableViewHeader from "../../../components/organisms/custom-table-header"
import DraftOrderTable from "../../../components/templates/draft-order-table"
import { useWidgets } from "../../../providers/widget-provider"
import NewOrderFormProvider from "../new/form"
import NewOrder from "../new/new-order"
import DraftOrderDetails from "./details"
@@ -18,6 +20,8 @@ const DraftOrderIndex = () => {
const view = "drafts"
const [showNewOrder, setShowNewOrder] = useState(false)
const { getWidgets } = useWidgets()
const actions = useMemo(() => {
return [
{
@@ -29,7 +33,17 @@ const DraftOrderIndex = () => {
}, [view])
return (
<div className="flex h-full grow flex-col">
<div className="gap-y-xsmall flex h-full grow flex-col">
{getWidgets("draft_order.list.before").map((Widget, i) => {
return (
<WidgetContainer
key={i}
entity={null}
injectionZone="draft_order.list.before"
widget={Widget}
/>
)
})}
<div className="flex w-full grow flex-col">
<BodyCard
customHeader={
@@ -48,8 +62,18 @@ const DraftOrderIndex = () => {
>
<DraftOrderTable />
</BodyCard>
<Spacer />
</div>
{getWidgets("draft_order.list.after").map((Widget, i) => {
return (
<WidgetContainer
key={i}
entity={null}
injectionZone="draft_order.list.after"
widget={Widget}
/>
)
})}
<Spacer />
{showNewOrder && (
<NewOrderFormProvider>
<NewOrder onDismiss={() => setShowNewOrder(false)} />
@@ -3,6 +3,8 @@ import { Route, Routes, useNavigate } from "react-router-dom"
import { useAdminCreateBatchJob } from "medusa-react"
import Spacer from "../../components/atoms/spacer"
import RouteContainer from "../../components/extensions/route-container"
import WidgetContainer from "../../components/extensions/widget-container"
import Button from "../../components/fundamentals/button"
import ExportIcon from "../../components/fundamentals/icons/export-icon"
import BodyCard from "../../components/organisms/body-card"
@@ -12,6 +14,8 @@ import OrderTable from "../../components/templates/order-table"
import useNotification from "../../hooks/use-notification"
import useToggleState from "../../hooks/use-toggle-state"
import { usePolling } from "../../providers/polling-provider"
import { useRoutes } from "../../providers/route-provider"
import { useWidgets } from "../../providers/widget-provider"
import { getErrorMessage } from "../../utils/error-messages"
import Details from "./details"
import { transformFiltersAsExportContext } from "./utils"
@@ -35,6 +39,8 @@ const OrderIndex = () => {
state: exportModalOpen,
} = useToggleState(false)
const { getWidgets } = useWidgets()
const actions = useMemo(() => {
return [
<Button
@@ -73,7 +79,17 @@ const OrderIndex = () => {
return (
<>
<div className="flex h-full grow flex-col">
<div className="gap-y-xsmall flex h-full grow flex-col">
{getWidgets("order.list.before").map((w, i) => {
return (
<WidgetContainer
key={i}
injectionZone={"order.list.before"}
widget={w}
entity={undefined}
/>
)
})}
<div className="flex w-full grow flex-col">
<BodyCard
customHeader={
@@ -92,8 +108,18 @@ const OrderIndex = () => {
>
<OrderTable setContextFilters={setContextFilters} />
</BodyCard>
<Spacer />
</div>
{getWidgets("order.list.after").map((w, i) => {
return (
<WidgetContainer
key={i}
injectionZone={"order.list.after"}
widget={w}
entity={undefined}
/>
)
})}
<Spacer />
</div>
{exportModalOpen && (
<ExportModal
@@ -108,10 +134,23 @@ const OrderIndex = () => {
}
const Orders = () => {
const { getNestedRoutes } = useRoutes()
const nestedRoutes = getNestedRoutes("/products")
return (
<Routes>
<Route index element={<OrderIndex />} />
<Route path="/:id" element={<Details />} />
{nestedRoutes.map((r, i) => {
return (
<Route
path={r.path}
key={i}
element={<RouteContainer route={r} previousPath={"/orders"} />}
/>
)
})}
</Routes>
)
}
@@ -171,6 +171,10 @@ function ImportPrices(props: ImportPricesProps) {
}
}
const templateLink = process.env.ADMIN_PATH
? `${process.env.ADMIN_PATH}/temp/price-list-import-template.csv`
: `/temp/price-list-import-template.csv`
return (
<UploadModal
type="prices"
@@ -186,7 +190,7 @@ function ImportPrices(props: ImportPricesProps) {
summary={getSummary()}
onFileRemove={onFileRemove}
processUpload={processUpload}
templateLink="/temp/price-list-import-template.csv"
templateLink={templateLink}
/>
)
}
@@ -1,7 +1,11 @@
import { useAdminPriceList } from "medusa-react"
import { useParams } from "react-router-dom"
import { useNavigate, useParams } from "react-router-dom"
import BackButton from "../../../components/atoms/back-button"
import Spinner from "../../../components/atoms/spinner"
import WidgetContainer from "../../../components/extensions/widget-container"
import RawJSON from "../../../components/organisms/raw-json"
import { useWidgets } from "../../../providers/widget-provider"
import { getErrorStatus } from "../../../utils/get-error-status"
import { mapPriceListToFormValues } from "../pricing-form/form/mappers"
import { PriceListFormProvider } from "../pricing-form/form/pricing-form-context"
import Header from "./sections/header"
@@ -9,8 +13,33 @@ import PricesDetails from "./sections/prices-details"
const PricingDetails = () => {
const { id } = useParams()
const navigate = useNavigate()
const { price_list, isLoading } = useAdminPriceList(id!)
const { price_list, isLoading, error } = useAdminPriceList(id!)
const { getWidgets } = useWidgets()
if (error) {
const errorStatus = getErrorStatus(error)
if (errorStatus) {
// If the product is not found, redirect to the 404 page
if (errorStatus.status === 404) {
navigate("/404")
return null
}
}
// Let the error boundary handle the error
throw error
}
if (isLoading || !price_list) {
return (
<div className="flex h-[calc(100vh-64px)] w-full items-center justify-center">
<Spinner variant="secondary" />
</div>
)
}
return (
<div className="pb-large">
@@ -19,18 +48,37 @@ const PricingDetails = () => {
path="/a/pricing"
className="mb-xsmall"
/>
<PriceListFormProvider priceList={mapPriceListToFormValues(price_list)}>
<div className="gap-y-xsmall flex flex-col">
{getWidgets("price_list.details.before").map((w, i) => {
return (
<WidgetContainer
key={i}
entity={price_list}
injectionZone="price_list.details.before"
widget={w}
/>
)
})}
{!isLoading && price_list ? (
<PriceListFormProvider priceList={mapPriceListToFormValues(price_list)}>
<div className="gap-y-xsmall flex flex-col">
<Header priceList={price_list} />
<Header priceList={price_list} />
<PricesDetails id={price_list?.id} />
<PricesDetails id={price_list?.id} />
<RawJSON data={price_list} title="Raw price list" />
</div>
</PriceListFormProvider>
) : null}
{getWidgets("price_list.details.after").map((w, i) => {
return (
<WidgetContainer
key={i}
entity={price_list}
injectionZone="price_list.details.after"
widget={w}
/>
)
})}
<RawJSON data={price_list} title="Raw price list" />
</div>
</PriceListFormProvider>
</div>
)
}
@@ -1,8 +1,12 @@
import { Route, Routes, useNavigate } from "react-router-dom"
import Spacer from "../../components/atoms/spacer"
import RouteContainer from "../../components/extensions/route-container"
import WidgetContainer from "../../components/extensions/widget-container"
import PlusIcon from "../../components/fundamentals/icons/plus-icon"
import BodyCard from "../../components/organisms/body-card"
import TableViewHeader from "../../components/organisms/custom-table-header"
import { useRoutes } from "../../providers/route-provider"
import { useWidgets } from "../../providers/widget-provider"
import PricingDetails from "./details"
import New from "./new"
import PricingTable from "./pricing-table"
@@ -18,8 +22,20 @@ const PricingIndex = () => {
},
]
const { getWidgets } = useWidgets()
return (
<div className="flex h-full flex-col">
<div className="gap-y-xsmall flex h-full flex-col">
{getWidgets("price_list.list.before").map((w, index) => {
return (
<WidgetContainer
key={index}
widget={w}
entity={null}
injectionZone="price_list.list.before"
/>
)
})}
<div className="flex w-full grow flex-col">
<BodyCard
actionables={actionables}
@@ -35,11 +51,24 @@ const PricingIndex = () => {
}
const Pricing = () => {
const { getNestedRoutes } = useRoutes()
const nestedRoutes = getNestedRoutes("/pricing")
return (
<Routes>
<Route index element={<PricingIndex />} />
<Route path="/new" element={<New />} />
<Route path="/:id" element={<PricingDetails />} />
{nestedRoutes.map((r, i) => {
return (
<Route
path={r.path}
key={i}
element={<RouteContainer route={r} previousPath={"/pricing"} />}
/>
)
})}
</Routes>
)
}
@@ -1,11 +1,28 @@
import { Route, Routes } from "react-router-dom"
import RouteContainer from "../../components/extensions/route-container"
import { useRoutes } from "../../providers/route-provider"
import ProductCategoryIndex from "./pages"
const ProductCategories = () => {
const { getNestedRoutes } = useRoutes()
const nestedRoutes = getNestedRoutes("/product-categories")
return (
<Routes>
<Route index element={<ProductCategoryIndex />} />
{nestedRoutes.map((r, i) => {
return (
<Route
path={r.path}
key={i}
element={
<RouteContainer route={r} previousPath={"/product-categories"} />
}
/>
)
})}
</Routes>
)
}
@@ -177,6 +177,10 @@ function ImportProducts(props: ImportProductsProps) {
}
}
const templateLink = process.env.ADMIN_PATH
? `${process.env.ADMIN_PATH}/temp/product-import-template.csv`
: "/temp/product-import-template.csv"
return (
<UploadModal
type="products"
@@ -190,7 +194,7 @@ function ImportProducts(props: ImportProductsProps) {
onFileRemove={onFileRemove}
processUpload={processUpload}
fileTitle={"products list"}
templateLink="/temp/product-import-template.csv"
templateLink={templateLink}
errorMessage={batchJob?.result?.errors?.join(" \n")}
description2Title="Unsure about how to arrange your list?"
description2Text="Download the template below to ensure you are following the correct format."
@@ -2,18 +2,22 @@ import { useAdminProduct } from "medusa-react"
import { useNavigate, useParams } from "react-router-dom"
import BackButton from "../../../components/atoms/back-button"
import Spinner from "../../../components/atoms/spinner"
import WidgetContainer from "../../../components/extensions/widget-container"
import ProductAttributesSection from "../../../components/organisms/product-attributes-section"
import ProductGeneralSection from "../../../components/organisms/product-general-section"
import ProductMediaSection from "../../../components/organisms/product-media-section"
import ProductRawSection from "../../../components/organisms/product-raw-section"
import ProductThumbnailSection from "../../../components/organisms/product-thumbnail-section"
import ProductVariantsSection from "../../../components/organisms/product-variants-section"
import { useWidgets } from "../../../providers/widget-provider"
import { getErrorStatus } from "../../../utils/get-error-status"
const Edit = () => {
const { id } = useParams()
const navigate = useNavigate()
const { getWidgets } = useWidgets()
const { product, status, error } = useAdminProduct(id || "")
if (error) {
@@ -32,7 +36,6 @@ const Edit = () => {
}
if (status === "loading" || !product) {
// temp, perhaps use skeletons?
return (
<div className="flex h-[calc(100vh-64px)] w-full items-center justify-center">
<Spinner variant="secondary" />
@@ -47,16 +50,38 @@ const Edit = () => {
label="Back to Products"
className="mb-xsmall"
/>
<div className="gap-x-base grid grid-cols-12">
<div className="gap-y-xsmall col-span-8 flex flex-col">
<ProductGeneralSection product={product} />
<ProductVariantsSection product={product} />
<ProductAttributesSection product={product} />
<ProductRawSection product={product} />
</div>
<div className="gap-y-xsmall col-span-4 flex flex-col">
<ProductThumbnailSection product={product} />
<ProductMediaSection product={product} />
<div className="gap-y-xsmall flex flex-col">
{getWidgets("product.details.before").map((w, i) => {
return (
<WidgetContainer
key={i}
injectionZone={"product.details.before"}
widget={w}
entity={product}
/>
)
})}
<div className="gap-x-base grid grid-cols-12">
<div className="gap-y-xsmall col-span-8 flex flex-col">
<ProductGeneralSection product={product} />
<ProductVariantsSection product={product} />
<ProductAttributesSection product={product} />
{getWidgets("product.details.after").map((w, i) => {
return (
<WidgetContainer
key={i}
injectionZone={"product.details.after"}
widget={w}
entity={product}
/>
)
})}
<ProductRawSection product={product} />
</div>
<div className="gap-y-xsmall col-span-4 flex flex-col">
<ProductThumbnailSection product={product} />
<ProductMediaSection product={product} />
</div>
</div>
</div>
</div>
@@ -1,12 +1,27 @@
import { Route, Routes } from "react-router-dom"
import RouteContainer from "../../components/extensions/route-container"
import { useRoutes } from "../../providers/route-provider"
import Edit from "./edit"
import Overview from "./overview"
const ProductsRoute = () => {
const { getNestedRoutes } = useRoutes()
const nestedRoutes = getNestedRoutes("/products")
return (
<Routes>
<Route index element={<Overview />} />
<Route path="/:id" element={<Edit />} />
{nestedRoutes.map((r, i) => {
return (
<Route
path={r.path}
key={i}
element={<RouteContainer route={r} previousPath={"/products"} />}
/>
)
})}
</Routes>
)
}
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react"
import { useLocation, useNavigate } from "react-router-dom"
import Fade from "../../../components/atoms/fade-wrapper"
import Spacer from "../../../components/atoms/spacer"
import WidgetContainer from "../../../components/extensions/widget-container"
import Button from "../../../components/fundamentals/button"
import ExportIcon from "../../../components/fundamentals/icons/export-icon"
import PlusIcon from "../../../components/fundamentals/icons/plus-icon"
@@ -16,6 +17,7 @@ import ProductTable from "../../../components/templates/product-table"
import useNotification from "../../../hooks/use-notification"
import useToggleState from "../../../hooks/use-toggle-state"
import { usePolling } from "../../../providers/polling-provider"
import { useWidgets } from "../../../providers/widget-provider"
import { getErrorMessage } from "../../../utils/error-messages"
import ImportProducts from "../batch-job/import"
import NewProduct from "../new"
@@ -30,7 +32,10 @@ const Overview = () => {
state: createProductState,
close: closeProductCreate,
open: openProductCreate,
} = useToggleState()
} = useToggleState(
!location.search.includes("view=collections") &&
location.search.includes("modal=new")
)
const { resetInterval } = usePolling()
const createBatchJob = useAdminCreateBatchJob()
@@ -39,6 +44,8 @@ const Overview = () => {
const createCollection = useAdminCreateCollection()
const { getWidgets } = useWidgets()
useEffect(() => {
if (location.search.includes("?view=collections")) {
setView("collections")
@@ -110,13 +117,19 @@ const Overview = () => {
open: openExportModal,
close: closeExportModal,
state: exportModalOpen,
} = useToggleState(false)
} = useToggleState(
!location.search.includes("view=collections") &&
location.search.includes("modal=export")
)
const {
open: openImportModal,
close: closeImportModal,
state: importModalOpen,
} = useToggleState(false)
} = useToggleState(
!location.search.includes("view=collections") &&
location.search.includes("modal=import")
)
const handleCreateCollection = async (data, colMetadata) => {
const metadata = colMetadata
@@ -163,7 +176,17 @@ const Overview = () => {
return (
<>
<div className="flex h-full grow flex-col">
<div className="gap-y-xsmall flex h-full grow flex-col">
{getWidgets("product.list.before").map((w, i) => {
return (
<WidgetContainer
key={i}
injectionZone={"product.list.before"}
widget={w}
entity={undefined}
/>
)
})}
<div className="flex w-full grow flex-col">
<BodyCard
forceDropdown={false}
@@ -181,6 +204,16 @@ const Overview = () => {
</BodyCard>
<Spacer />
</div>
{getWidgets("product.list.after").map((w, i) => {
return (
<WidgetContainer
key={i}
injectionZone={"product.list.after"}
widget={w}
entity={undefined}
/>
)
})}
</div>
{showNewCollection && (
@@ -1,11 +1,31 @@
import { Route, Routes } from "react-router-dom"
import RouteContainer from "../../components/extensions/route-container"
import { useRoutes } from "../../providers/route-provider"
import Index from "./pages"
const PublishableApiKeysRoute = () => {
const { getNestedRoutes } = useRoutes()
const nestedRoutes = getNestedRoutes("/publishable-api-keys")
return (
<Routes>
<Route index element={<Index />} />
{nestedRoutes.map((r, i) => {
return (
<Route
path={r.path}
key={i}
element={
<RouteContainer
route={r}
previousPath={"/publishable-api-keys"}
/>
}
/>
)
})}
</Routes>
)
}
@@ -1,11 +1,28 @@
import Details from "./pages/details"
import { Route, Routes } from "react-router-dom"
import RouteContainer from "../../components/extensions/route-container"
import { useRoutes } from "../../providers/route-provider"
import Details from "./pages/details"
const SalesChannels = () => {
const { getNestedRoutes } = useRoutes()
const nestedRoutes = getNestedRoutes("/sales-channels")
return (
<Routes>
<Route index element={<Details />} />
<Route path="/:id" element={<Details />} />
{nestedRoutes.map((r, i) => {
return (
<Route
path={r.path}
key={i}
element={
<RouteContainer route={r} previousPath={"/sales-channels"} />
}
/>
)
})}
</Routes>
)
}
@@ -1,18 +1,21 @@
import React from "react"
import { Route, Routes } from "react-router-dom"
import SettingsCard from "../../components/atoms/settings-card"
import Spacer from "../../components/atoms/spacer"
import SettingContainer from "../../components/extensions/setting-container"
import SettingsPageErrorElement from "../../components/extensions/setting-container/setting-error-element"
import FeatureToggle from "../../components/fundamentals/feature-toggle"
import ArrowUTurnLeft from "../../components/fundamentals/icons/arrow-uturn-left"
import ChannelsIcon from "../../components/fundamentals/icons/channels-icon"
import CoinsIcon from "../../components/fundamentals/icons/coins-icon"
import CrosshairIcon from "../../components/fundamentals/icons/crosshair-icon"
import DollarSignIcon from "../../components/fundamentals/icons/dollar-sign-icon"
import GearIcon from "../../components/fundamentals/icons/gear-icon"
import HappyIcon from "../../components/fundamentals/icons/happy-icon"
import KeyIcon from "../../components/fundamentals/icons/key-icon"
import MailIcon from "../../components/fundamentals/icons/mail-icon"
import MapPinIcon from "../../components/fundamentals/icons/map-pin-icon"
import TaxesIcon from "../../components/fundamentals/icons/taxes-icon"
import TruckIcon from "../../components/fundamentals/icons/truck-icon"
import UsersIcon from "../../components/fundamentals/icons/users-icon"
import SettingsOverview from "../../components/templates/settings-overview"
import { useSettings } from "../../providers/setting-provider"
import CurrencySettings from "./currencies"
import Details from "./details"
import PersonalInformation from "./personal-information"
@@ -21,95 +24,164 @@ import ReturnReasons from "./return-reasons"
import Taxes from "./taxes"
import Users from "./users"
type SettingsCardType = {
heading: string
description: string
icon?: React.ComponentType
to: string
feature_flag?: string
}
const settings: SettingsCardType[] = [
{
heading: "API Key Management",
description: "Create and manage API keys",
icon: KeyIcon,
to: "/a/publishable-api-keys",
feature_flag: "publishable_api_keys",
},
{
heading: "Currencies",
description: "Manage the currencies of your store",
icon: CoinsIcon,
to: "/a/settings/currencies",
},
{
heading: "Personal Information",
description: "Manage your Medusa profile",
icon: HappyIcon,
to: "/a/settings/personal-information",
},
{
heading: "Regions",
description: "Manage shipping, payment, and fulfillment across regions",
icon: MapPinIcon,
to: "/a/settings/regions",
},
{
heading: "Return Reasons",
description: "Manage resons for returned items",
icon: ArrowUTurnLeft,
to: "/a/settings/return-reasons",
},
{
heading: "Sales Channels",
description: "Control which product are available in which channels",
icon: ChannelsIcon,
feature_flag: "sales_channels",
to: "/a/sales-channels",
},
{
heading: "Store Details",
description: "Manage your business details",
icon: CrosshairIcon,
to: "/a/settings/details",
},
{
heading: "Taxes",
description: "Manage taxes across regions and products",
icon: TaxesIcon,
to: "/a/settings/taxes",
},
{
heading: "The Team",
description: "Manage users of your Medusa Store",
icon: UsersIcon,
to: "/a/settings/team",
},
]
const renderCard = ({
heading,
description,
icon,
to,
feature_flag,
}: SettingsCardType) => {
const Icon = icon || GearIcon
const card = (
<SettingsCard
heading={heading}
description={description}
icon={<Icon />}
to={to}
/>
)
if (feature_flag) {
return <FeatureToggle featureFlag={feature_flag}>{card}</FeatureToggle>
}
return card
}
const SettingsIndex = () => {
const { getCards } = useSettings()
const extensionCards = getCards()
return (
<SettingsOverview>
<SettingsCard
heading={"Regions"}
description={"Manage the markets you will operate within"}
icon={<MapPinIcon />}
to={`/a/settings/regions`}
/>
<SettingsCard
heading={"Currencies"}
description={"Manage the markets you will operate within"}
icon={<CoinsIcon />}
to={`/a/settings/currencies`}
/>
<SettingsCard
heading={"Store Details"}
description={"Manage your business details"}
icon={<CrosshairIcon />}
to={`/a/settings/details`}
/>
<SettingsCard
heading={"Shipping"}
description={"Manage shipping profiles"}
icon={<TruckIcon />}
to={`/a/settings/shipping-profiles`}
disabled={true}
/>
<SettingsCard
heading={"Return Reasons"}
description={"Manage Order settings"}
icon={<DollarSignIcon />}
to={`/a/settings/return-reasons`}
/>
<SettingsCard
heading={"The Team"}
description={"Manage users of your Medusa Store"}
icon={<UsersIcon />}
to={`/a/settings/team`}
/>
<SettingsCard
heading={"Personal Information"}
description={"Manage your Medusa profile"}
icon={<HappyIcon />}
to={`/a/settings/personal-information`}
/>
<SettingsCard
heading={"hello@medusajs.com"}
description={"Cant find the answers youre looking for?"}
icon={<MailIcon />}
externalLink={"mailto: hello@medusajs.com"}
/>
<SettingsCard
heading={"Tax Settings"}
description={"Manage taxes across regions and products"}
icon={<TaxesIcon />}
to={`/a/settings/taxes`}
/>
<FeatureToggle featureFlag="sales_channels">
<SettingsCard
heading={"Sales channels"}
description={"Control which products are available in which channels"}
icon={<ChannelsIcon />}
to={`/a/sales-channels`}
/>
</FeatureToggle>
<FeatureToggle featureFlag="publishable_api_keys">
<SettingsCard
heading={"API key management"}
description={"Create and manage API keys"}
icon={<KeyIcon />}
to={`/a/publishable-api-keys`}
/>
</FeatureToggle>
</SettingsOverview>
<div className="gap-y-xlarge flex flex-col">
<div className="gap-y-large flex flex-col">
<div className="gap-y-2xsmall flex flex-col">
<h2 className="inter-xlarge-semibold">General</h2>
<p className="inter-base-regular text-grey-50">
Manage the general settings for your store
</p>
</div>
<div className="medium:grid-cols-2 gap-y-xsmall grid grid-cols-1 gap-x-4">
{settings.map((s) => renderCard(s))}
</div>
</div>
{extensionCards.length > 0 && (
<div className="gap-y-large flex flex-col">
<div className="gap-y-2xsmall flex flex-col">
<h2 className="inter-xlarge-semibold">Extensions</h2>
<p className="inter-base-regular text-grey-50">
Manage the settings for your store&apos;s extensions
</p>
</div>
<div className="medium:grid-cols-2 gap-y-xsmall grid grid-cols-1 gap-x-4">
{getCards().map((s) =>
renderCard({
heading: s.label,
description: s.description,
icon: s.icon,
to: `/a/settings${s.path}`,
})
)}
</div>
</div>
)}
<Spacer />
</div>
)
}
const Settings = () => (
<Routes>
<Route index element={<SettingsIndex />} />
<Route path="/details" element={<Details />} />
<Route path="/regions/*" element={<Regions />} />
<Route path="/currencies" element={<CurrencySettings />} />
<Route path="/return-reasons" element={<ReturnReasons />} />
<Route path="/team" element={<Users />} />
<Route path="/personal-information" element={<PersonalInformation />} />
<Route path="/taxes/*" element={<Taxes />} />
</Routes>
)
const Settings = () => {
const { getSettings } = useSettings()
return (
<Routes>
<Route index element={<SettingsIndex />} />
<Route path="/details" element={<Details />} />
<Route path="/regions/*" element={<Regions />} />
<Route path="/currencies" element={<CurrencySettings />} />
<Route path="/return-reasons" element={<ReturnReasons />} />
<Route path="/team" element={<Users />} />
<Route path="/personal-information" element={<PersonalInformation />} />
<Route path="/taxes/*" element={<Taxes />} />
{getSettings().map((s) => (
<Route
key={s.path}
path={s.path}
element={<SettingContainer Page={s.Page} />}
errorElement={<SettingsPageErrorElement origin={s.origin} />}
/>
))}
</Routes>
)
}
export default Settings
Binary file not shown.
@@ -0,0 +1,25 @@
import { Notify } from "../types/extensions"
import useNotification from "./use-notification"
export const useExtensionBaseProps = () => {
const notification = useNotification()
const notify: Notify = {
success: (title: string, message: string) => {
notification(title, message, "success")
},
error: (title: string, message: string) => {
notification(title, message, "error")
},
warn: (title: string, message: string) => {
notification(title, message, "warning")
},
info: (title: string, message: string) => {
notification(title, message, "info")
},
}
return {
notify,
}
}
-3
View File
@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+1
View File
@@ -0,0 +1 @@
declare const __BASE__: string | undefined
+14 -1
View File
@@ -1,9 +1,22 @@
import ReactDOM from "react-dom/client"
import "./assets/styles/global.css"
import MedusaApp from "./medusa-app"
import { ExtensionsEntry } from "./types/extensions"
async function run() {
const app = new MedusaApp()
let exts: ExtensionsEntry[] = []
try {
const { default: extensions } = (await import(
// @ts-ignore - this file is generated at build time
"./extensions/_main-entry.ts"
)) as { default: ExtensionsEntry[] }
exts = extensions
} catch (_) {
// noop - no extensions
}
const app = new MedusaApp({ entries: exts })
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
app.render()
+42 -1
View File
@@ -1,12 +1,53 @@
import React from "react"
import App from "./App"
import { Providers } from "./providers/providers"
import RouteRegistry from "./registries/route-registry"
import SettingRegistry from "./registries/setting-registry"
import WidgetRegistry from "./registries/widget-registry"
import { ExtensionsEntry } from "./types/extensions"
import {
isRouteExtension,
isSettingExtension,
isWidgetExtension,
} from "./utils/extensions"
type MedusaAppConfig = {
entries?: ExtensionsEntry[]
}
class MedusaApp {
private widgets: WidgetRegistry = new WidgetRegistry()
private routes: RouteRegistry = new RouteRegistry()
private settings: SettingRegistry = new SettingRegistry()
constructor({ entries = [] }: MedusaAppConfig) {
entries.forEach((entry) => {
const origin = entry.identifier
entry.extensions.forEach((extension) => {
if (isRouteExtension(extension)) {
this.routes.register(origin, extension)
}
if (isWidgetExtension(extension)) {
this.widgets.register(origin, extension)
}
if (isSettingExtension(extension)) {
this.settings.register(origin, extension)
}
})
})
}
render() {
return (
<React.StrictMode>
<Providers>
<Providers
widgetRegistry={this.widgets}
routeRegistry={this.routes}
settingRegistry={this.settings}
>
<App />
</Providers>
</React.StrictMode>
+23 -1
View File
@@ -2,6 +2,8 @@ import { DndProvider } from "react-dnd"
import { HTML5Backend } from "react-dnd-html5-backend"
import { useHotkeys } from "react-hotkeys-hook"
import { Route, Routes, useNavigate } from "react-router-dom"
import RouteContainer from "../components/extensions/route-container"
import RouteErrorElement from "../components/extensions/route-container/route-error-element"
import PrivateRoute from "../components/private-route"
import SEO from "../components/seo"
import Layout from "../components/templates/layout"
@@ -15,12 +17,14 @@ import Oauth from "../domain/oauth"
import Orders from "../domain/orders"
import DraftOrders from "../domain/orders/draft-orders"
import Pricing from "../domain/pricing"
import ProductCategories from "../domain/product-categories"
import ProductsRoute from "../domain/products"
import PublishableApiKeys from "../domain/publishable-api-keys"
import SalesChannels from "../domain/sales-channels"
import Settings from "../domain/settings"
import { AnalyticsProvider } from "../providers/analytics-provider"
import ProductCategories from "../domain/product-categories"
import { useRoutes } from "../providers/route-provider"
import { isRoute } from "../utils/extensions"
const IndexPage = () => {
const navigate = useNavigate()
@@ -35,6 +39,10 @@ const IndexPage = () => {
}
const DashboardRoutes = () => {
const { getTopLevelRoutes } = useRoutes()
const injectedRoutes = getTopLevelRoutes() || []
return (
<AnalyticsProvider writeKey={WRITE_KEY}>
<DndProvider backend={HTML5Backend}>
@@ -61,6 +69,20 @@ const DashboardRoutes = () => {
element={<PublishableApiKeys />}
/>
<Route path="inventory/*" element={<Inventory />} />
{injectedRoutes.map((route, index) => {
return (
<Route
key={index}
path={`/${route.path}/*`}
errorElement={
<RouteErrorElement
origin={isRoute(route) ? route.origin : ""}
/>
}
element={<RouteContainer route={route} />}
/>
)
})}
</Routes>
</Layout>
</DndProvider>
+85 -48
View File
@@ -1,4 +1,4 @@
import { useAdminAcceptInvite } from "medusa-react"
import { useAdminAcceptInvite, useAdminLogin } from "medusa-react"
import qs from "qs"
import { useState } from "react"
import { useForm } from "react-hook-form"
@@ -12,12 +12,18 @@ import PublicLayout from "../components/templates/login-layout"
import useNotification from "../hooks/use-notification"
import { getErrorMessage } from "../utils/error-messages"
import FormValidator from "../utils/form-validator"
import { analytics, useAdminCreateAnalyticsConfig } from "../services/analytics"
import AnalyticsConfigForm, {
AnalyticsConfigFormType,
} from "../components/organisms/analytics-config-form"
import { nestedForm } from "../utils/nested-form"
type FormValues = {
password: string
repeat_password: string
first_name: string
last_name: string
analytics: AnalyticsConfigFormType
}
const InvitePage = () => {
@@ -25,7 +31,14 @@ const InvitePage = () => {
const parsed = qs.parse(location.search.substring(1))
const [signUp, setSignUp] = useState(false)
let token: Object | null = null
const first_run = !!parsed.first_run
let token: {
iat: number
invite_id: string
role: string
user_email: string
} | null = null
if (parsed?.token) {
try {
token = decodeToken(parsed.token as string)
@@ -34,25 +47,41 @@ const InvitePage = () => {
}
}
const {
register,
handleSubmit,
formState: { errors },
setError,
} = useForm<FormValues>({
const form = useForm<FormValues>({
defaultValues: {
first_name: "",
last_name: "",
password: "",
repeat_password: "",
analytics: {
opt_out: false,
anonymize: false,
},
},
})
const { mutate, isLoading } = useAdminAcceptInvite()
const {
register,
handleSubmit,
formState: { errors },
setError,
} = form
const { mutateAsync: acceptInvite, isLoading: acceptInviteIsLoading } =
useAdminAcceptInvite()
const {
mutateAsync: createAnalyticsConfig,
isLoading: createAnalyticsConfigIsLoading,
} = useAdminCreateAnalyticsConfig()
const { mutateAsync: doLogin, isLoading: loginIsLoading } = useAdminLogin()
const isLoading =
acceptInviteIsLoading || createAnalyticsConfigIsLoading || loginIsLoading
const navigate = useNavigate()
const notification = useNotification()
const handleAcceptInvite = handleSubmit((data: FormValues) => {
const handleAcceptInvite = handleSubmit(async (data: FormValues) => {
if (data.password !== data.repeat_password) {
setError(
"repeat_password",
@@ -68,24 +97,35 @@ const InvitePage = () => {
return
}
mutate(
{
try {
await acceptInvite({
token: parsed.token as string,
user: {
first_name: data.first_name,
last_name: data.last_name,
password: data.password,
},
},
{
onSuccess: () => {
navigate("/login")
},
onError: (err) => {
notification("Error", getErrorMessage(err), "error")
},
})
await doLogin({ email: token!.user_email, password: data.password })
const shouldTrackEmail =
!data.analytics.anonymize &&
!data.analytics.opt_out &&
token?.user_email
await createAnalyticsConfig(data.analytics)
if (shouldTrackEmail) {
await analytics.track("userEmail", {
email: token?.user_email,
})
}
)
navigate("/a/orders")
} catch (err) {
notification("Error", getErrorMessage(err), "error")
}
})
if (!token) {
@@ -113,30 +153,13 @@ const InvitePage = () => {
<SEO title="Create Account" />
{signUp ? (
<form onSubmit={handleAcceptInvite}>
<div className="flex flex-col items-center">
<div className="flex w-[300px] flex-col items-center">
<h1 className="inter-xlarge-semibold mb-large text-[20px]">
Create your Medusa account
</h1>
<div className="gap-y-small flex flex-col">
<div>
<SigninInput
placeholder="First name"
{...register("first_name", {
required: FormValidator.required("First name"),
})}
autoComplete="given-name"
/>
<InputError errors={errors} name="first_name" />
</div>
<div>
<SigninInput
placeholder="Last name"
{...register("last_name", {
required: FormValidator.required("Last name"),
})}
autoComplete="family-name"
/>
<InputError errors={errors} name="last_name" />
<SigninInput readOnly placeholder={token.user_email} />
</div>
<div>
<SigninInput
@@ -160,10 +183,16 @@ const InvitePage = () => {
<InputError errors={errors} name="repeat_password" />
</div>
</div>
<div className="gap-y-small my-8 flex w-[300px] flex-col">
<AnalyticsConfigForm
form={nestedForm(form, "analytics")}
compact={true}
/>
</div>
<Button
variant="secondary"
size="medium"
className="mt-large w-[280px]"
className="mt-large w-[300px]"
loading={isLoading}
>
Create account
@@ -176,17 +205,25 @@ const InvitePage = () => {
) : (
<div className="flex flex-col items-center text-center">
<h1 className="inter-xlarge-semibold text-[20px]">
You have been invited to join the team
{first_run
? `Let's get you started!`
: `You have been invited to join the team`}
</h1>
<p className="inter-base-regular text-grey-50 mt-xsmall">
You can now join the team. Sign up below and get started
<br />
with your Medusa account right away.
</p>
{first_run ? (
<p className="inter-base-regular text-grey-50 mt-xsmall">
Create an admin account to access your <br /> Medusa dashboard.
</p>
) : (
<p className="inter-base-regular text-grey-50 mt-xsmall">
You can now join the team. Sign up below and get started
<br />
with your Medusa account right away.
</p>
)}
<Button
variant="secondary"
size="medium"
className="mt-xlarge w-[280px]"
className="mt-xlarge w-[300px]"
onClick={() => setSignUp(true)}
>
Sign up
-1
View File
@@ -38,7 +38,6 @@ const LoginPage = () => {
return (
<PublicLayout>
<SEO title="Login" />
{resetPassword ? (
<ResetTokenCard goBack={showLogin} />
) : (
@@ -2,21 +2,46 @@ import { PropsWithChildren } from "react"
import { HelmetProvider } from "react-helmet-async"
import { LayeredModalProvider } from "../components/molecules/modal/layered-modal"
import { SteppedProvider } from "../components/molecules/modal/stepped-modal"
import RouteRegistry from "../registries/route-registry"
import SettingRegistry from "../registries/setting-registry"
import WidgetRegistry from "../registries/widget-registry"
import { FeatureFlagProvider } from "./feature-flag-provider"
import { MedusaProvider } from "./medusa-provider"
import { PollingProvider } from "./polling-provider"
import { RouteProvider } from "./route-provider"
import { SettingProvider } from "./setting-provider"
import { WidgetProvider } from "./widget-provider"
type Props = PropsWithChildren<{
widgetRegistry: WidgetRegistry
routeRegistry: RouteRegistry
settingRegistry: SettingRegistry
}>
/**
* This component wraps all providers into a single component.
*/
export const Providers = ({ children }: PropsWithChildren) => {
export const Providers = ({
widgetRegistry,
routeRegistry,
settingRegistry,
children,
}: Props) => {
return (
<HelmetProvider>
<MedusaProvider>
<FeatureFlagProvider>
<PollingProvider>
<SteppedProvider>
<LayeredModalProvider>{children}</LayeredModalProvider>
<LayeredModalProvider>
<WidgetProvider registry={widgetRegistry}>
<RouteProvider registry={routeRegistry}>
<SettingProvider registry={settingRegistry}>
{children}
</SettingProvider>
</RouteProvider>
</WidgetProvider>
</LayeredModalProvider>
</SteppedProvider>
</PollingProvider>
</FeatureFlagProvider>
@@ -0,0 +1,55 @@
import React, { PropsWithChildren, useCallback, useMemo } from "react"
import RouteRegistry from "../registries/route-registry"
import { Link, Route, RouteSegment } from "../types/extensions"
type RouteContextType = {
getTopLevelRoutes: () => (Route | RouteSegment)[]
getNestedRoutes: (parent: string) => Route[]
getLinks: () => Link[]
}
const RouteContext = React.createContext<RouteContextType | null>(null)
export const useRoutes = () => {
const context = React.useContext(RouteContext)
if (!context) {
throw new Error("useRoutes must be used within a RouteContext")
}
return context
}
type RouteProviderProps = PropsWithChildren<{
registry: RouteRegistry
}>
export const RouteProvider = ({ registry, children }: RouteProviderProps) => {
const getTopLevelRoutes = useCallback(() => {
return registry.getTopLevelRoutes()
}, [registry])
const getNestedRoutes = useCallback(
(parent: string) => {
return registry.getNestedRoutes(parent)
},
[registry]
)
const getLinks = useCallback(() => {
return registry.getLinks()
}, [registry])
const values = useMemo(
() => ({
getTopLevelRoutes,
getNestedRoutes,
getLinks,
}),
[getTopLevelRoutes, getNestedRoutes, getLinks]
)
return (
<RouteContext.Provider value={values}>{children}</RouteContext.Provider>
)
}
@@ -0,0 +1,49 @@
import React, { PropsWithChildren, useCallback, useMemo } from "react"
import SettingRegistry from "../registries/setting-registry"
import { Card, Setting } from "../types/extensions"
type SettingContextType = {
getSettings: () => Setting[]
getCards: () => Card[]
}
const SettingContext = React.createContext<SettingContextType | null>(null)
export const useSettings = () => {
const context = React.useContext(SettingContext)
if (!context) {
throw new Error("useSettings must be used within a SettingContext")
}
return context
}
type SettingProviderProps = PropsWithChildren<{
registry: SettingRegistry
}>
export const SettingProvider = ({
registry,
children,
}: SettingProviderProps) => {
const getSettings = useCallback(() => {
return registry.getSettings()
}, [registry])
const getCards = useCallback(() => {
return registry.getCards()
}, [registry])
const values = useMemo(
() => ({
getSettings,
getCards,
}),
[getSettings, getCards]
)
return (
<SettingContext.Provider value={values}>{children}</SettingContext.Provider>
)
}
@@ -0,0 +1,38 @@
import React, { PropsWithChildren, useCallback, useMemo } from "react"
import WidgetRegistry from "../registries/widget-registry"
import { InjectionZone, Widget } from "../types/extensions"
type WidgetContextType = {
getWidgets: (injectionZone: InjectionZone) => Widget[]
}
const WidgetContext = React.createContext<WidgetContextType | null>(null)
export const useWidgets = () => {
const context = React.useContext(WidgetContext)
if (!context) {
throw new Error("useWidgets must be used within a WidgetContext")
}
return context
}
type WidgetProviderProps = PropsWithChildren<{
registry: WidgetRegistry
}>
export const WidgetProvider = ({ registry, children }: WidgetProviderProps) => {
const getWidgets = useCallback(
(injectionZone: InjectionZone) => {
return registry.getWidgets(injectionZone)
},
[registry]
)
const values = useMemo(() => ({ getWidgets }), [getWidgets])
return (
<WidgetContext.Provider value={values}>{children}</WidgetContext.Provider>
)
}
@@ -0,0 +1,133 @@
import { isForbiddenRoute } from "../constants/forbidden-routes"
import { Link, Route, RouteExtension, RouteSegment } from "../types/extensions"
type RouteNode = {
[pathSegment: string]: RouteNode | Route
}
class RouteRegistry {
private routes: RouteNode
private links: Link[]
constructor() {
this.routes = {}
this.links = []
}
register(origin: string, { Component, config }: RouteExtension) {
const { path, link } = config
const pathSegments = path.split("/").filter(Boolean)
let currentNode = this.routes
let finalSegment = ""
for (const segment of pathSegments) {
if (!currentNode[segment]) {
currentNode[segment] = {}
}
currentNode = currentNode[segment] as RouteNode
finalSegment = segment
}
if (currentNode.__route) {
if (process.env.NODE_ENV !== "production") {
console.warn(
`Route with path ${path} already registered by ${currentNode.__route.origin}.`
)
}
return
}
currentNode.__route = {
origin,
path: `/${finalSegment}`,
Page: Component,
}
if (link) {
this.links.push({
path,
label: link.label,
icon: link.icon,
})
}
}
getTopLevelRoutes(): (Route | RouteSegment)[] {
const topLevelRoutes = Object.entries(this.routes)
.filter(([key, _]) => !isForbiddenRoute(key))
.map(([key, node]) => {
return "__route" in node
? ((node as RouteNode).__route as Route)
: ({
path: `/${key}`,
} as RouteSegment)
})
return topLevelRoutes
}
getNestedRoutes(path: string) {
const segments = path.split("/").filter((s) => !!s)
const routes = this.routes
function getNode(segments: string[]) {
let currentNode: RouteNode | null = routes
for (const segment of segments) {
if (currentNode && currentNode[segment]) {
currentNode = currentNode[segment] as RouteNode
} else {
currentNode = null
break
}
}
return currentNode
}
const lastKnownNode = getNode(segments)
if (!lastKnownNode) {
return []
}
const nestedRoutes: Route[] = []
function getChildren(node: RouteNode, parent: string = "") {
const possiblePaths = Object.entries(node).filter(
([key, _value]) => key !== "__route"
)
for (const [key, value] of possiblePaths) {
const currentPath = `${parent}/${key}`
if (value && "__route" in value) {
const page = (value.__route as Route).Page
const origin = (value.__route as Route).origin
nestedRoutes.push({
origin,
path: currentPath,
Page: page,
})
}
if (value && typeof value === "object") {
getChildren(value as RouteNode, currentPath)
}
}
}
getChildren(lastKnownNode)
return nestedRoutes
}
getLinks(): Link[] {
return this.links
}
}
export default RouteRegistry
@@ -0,0 +1,60 @@
import { Card, Setting, SettingExtension } from "../types/extensions"
class SettingRegistry {
private settings: Record<string, Setting>
private cards: Card[]
constructor() {
this.settings = {}
this.cards = []
}
register(origin: string, { Component, config }: SettingExtension) {
const { path, card } = config
if (this.settings[path] !== undefined) {
if (process.env.NODE_ENV !== "production") {
console.warn(
`Settings page with path ${path} already registered by ${this.settings[path].origin}.`
)
}
return
}
this.settings[path] = {
origin,
path: path,
Page: Component,
}
this.cards.push({
path: path,
label: card.label,
description: card.description,
icon: card.icon,
})
}
/**
* Returns an array of settings sorted by path
* @returns {Setting[]} An array of settings sorted by path
*/
getSettings(): Setting[] {
return Object.values(this.settings).sort((a, b) => {
return a.path.localeCompare(b.path)
})
}
/**
* Returns an array of cards sorted by path
* @returns {Card[]} An array of cards sorted by path
*/
getCards(): Card[] {
return this.cards.sort((a, b) => {
return a.path.localeCompare(b.path)
})
}
}
export default SettingRegistry
@@ -0,0 +1,26 @@
import { InjectionZone, Widget, WidgetExtension } from "../types/extensions"
type Widgets = Map<InjectionZone, Widget[]>
class WidgetRegistry {
private widgets: Widgets = new Map()
public register(origin: string, widget: WidgetExtension) {
const { zone } = widget.config
const zones = Array.isArray(zone) ? zone : [zone]
for (const widgetZone of zones) {
const widgets = this.widgets.get(widgetZone) || []
widgets.push({ origin, Widget: widget.Component })
this.widgets.set(widgetZone, widgets)
}
}
public getWidgets(zone: InjectionZone) {
return this.widgets.get(zone) || []
}
}
export default WidgetRegistry
@@ -0,0 +1,151 @@
import type { ComponentType } from "react"
import { forbiddenRoutes } from "../constants/forbidden-routes"
import { injectionZones } from "../constants/injection-zones"
// Constants
export type InjectionZone = (typeof injectionZones)[number]
export type ForbiddenRoute = (typeof forbiddenRoutes)[number]
// Base config
interface BaseGeneratedConfig {
type: "route" | "setting" | "widget"
}
interface RoutingConfig {
path: string
}
// Route
export type LinkConfig = {
label: string
icon?: ComponentType
}
export type RouteConfig = {
link?: LinkConfig
}
export interface GeneratedRouteConfig
extends BaseGeneratedConfig,
RoutingConfig,
RouteConfig {
type: "route"
}
// Setting
export type CardConfig = {
label: string
description: string
icon?: ComponentType
}
export type SettingConfig = {
card: CardConfig
}
export interface GeneratedSettingConfig
extends BaseGeneratedConfig,
RoutingConfig,
SettingConfig {
type: "setting"
}
// Widget
export type WidgetConfig = {
zone: InjectionZone | InjectionZone[]
}
export interface GeneratedWidgetConfig
extends BaseGeneratedConfig,
WidgetConfig {
type: "widget"
}
// Extensions
export type WidgetExtension = {
Component: ComponentType
config: GeneratedWidgetConfig
}
export type RouteExtension = {
Component: ComponentType
config: GeneratedRouteConfig
}
export type SettingExtension = {
Component: ComponentType
config: GeneratedSettingConfig
}
export type Extension = WidgetExtension | RouteExtension | SettingExtension
// Injected types
export type Widget = {
origin: string
Widget: ComponentType<any>
}
export type RouteSegment = {
path: string
}
export type Route = {
origin: string
path: string
Page: ComponentType<any>
}
export type Setting = {
origin: string
path: string
Page: ComponentType<any>
}
export type Link = {
label: string
icon?: ComponentType<any>
path: string
}
export type Card = {
label: string
description: string
icon?: ComponentType<any>
path: string
}
// Entry
export type ExtensionsEntry = {
identifier: string
extensions: Extension[]
}
// General props
export type Notify = {
success: (title: string, message: string) => void
error: (title: string, message: string) => void
warn: (title: string, message: string) => void
info: (title: string, message: string) => void
}
export interface WidgetProps {
notify: Notify
}
export interface RouteProps {
notify: Notify
}
export interface SettingProps {
notify: Notify
}
@@ -0,0 +1,30 @@
import {
Extension,
Route,
RouteExtension,
RouteSegment,
SettingExtension,
WidgetExtension,
} from "../types/extensions"
export function isWidgetExtension(
extension: Extension
): extension is WidgetExtension {
return "config" in extension && extension.config.type === "widget"
}
export function isRouteExtension(
extension: Extension
): extension is RouteExtension {
return "config" in extension && extension.config.type === "route"
}
export function isSettingExtension(
extension: Extension
): extension is SettingExtension {
return "config" in extension && extension.config.type === "setting"
}
export function isRoute(route: Route | RouteSegment): route is Route {
return "Page" in route
}
-3
View File
@@ -1,3 +0,0 @@
// / <reference types="vite/client" />
declare const __BASE__: string | undefined
declare const __MEDUSA_BACKEND_URL__: string