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:
co-authored by
Rares Stefan
Oli Juhl
parent
26c78bbc03
commit
f1a05f4725
@@ -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
|
||||
+75
@@ -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
|
||||
+8
@@ -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
|
||||
+77
@@ -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
|
||||
+7
@@ -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",
|
||||
}
|
||||
+31
@@ -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
|
||||
}
|
||||
+136
@@ -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">
|
||||
|
||||
-59
@@ -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")
|
||||
})
|
||||
})
|
||||
-59
@@ -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)
|
||||
})
|
||||
})
|
||||
-119
@@ -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)
|
||||
})
|
||||
})
|
||||
-80
@@ -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)
|
||||
})
|
||||
})
|
||||
-59
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
-72
@@ -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)
|
||||
})
|
||||
})
|
||||
-52
@@ -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()
|
||||
})
|
||||
})
|
||||
-53
@@ -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")
|
||||
})
|
||||
})
|
||||
-148
@@ -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={"Can’t find the answers you’re 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'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,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare const __BASE__: string | undefined
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -1,3 +0,0 @@
|
||||
// / <reference types="vite/client" />
|
||||
declare const __BASE__: string | undefined
|
||||
declare const __MEDUSA_BACKEND_URL__: string
|
||||
Reference in New Issue
Block a user