feat(admin,admin-ui,medusa): Add Medusa Admin plugin (#3334)

This commit is contained in:
Kasper Fabricius Kristensen
2023-03-03 10:09:16 +01:00
committed by GitHub
parent d6b1ad1ccd
commit 40de54b010
928 changed files with 85441 additions and 384 deletions

View File

@@ -0,0 +1,212 @@
import { AnalyticsBrowser } from "@segment/analytics-next"
import { useAdminGetSession, useAdminStore, useAdminUsers } from "medusa-react"
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react"
import { useLocation } from "react-router-dom"
import Fade from "../components/atoms/fade-wrapper"
import AnalyticsPreferencesModal from "../components/organisms/analytics-preferences"
import { useDebounce } from "../hooks/use-debounce"
import { useFeatureFlag } from "../providers/feature-flag-provider"
import { useAdminAnalyticsConfig } from "../services/analytics"
type Props = {
children?: React.ReactNode
writeKey: string
}
type Event =
| "numProducts"
| "numOrders"
| "numDiscounts"
| "numUsers"
| "regions"
| "currencies"
| "storeName"
type AnalyticsContextType = {
trackCurrencies: (properties: TrackCurrenciesPayload) => void
trackNumberOfOrders: (properties: TrackCountPayload) => void
trackNumberOfDiscounts: (properties: TrackCountPayload) => void
trackNumberOfProducts: (properties: TrackCountPayload) => void
trackRegions: (properties: TrackRegionsPayload) => void
setSubmittingConfig: (status: boolean) => void
}
const AnalyticsContext = createContext<AnalyticsContextType | null>(null)
export const AnalyticsProvider = ({ writeKey, children }: Props) => {
const [submittingConfig, setSubmittingConfig] = useState(false)
const { analytics_config: config, isLoading } = useAdminAnalyticsConfig()
const location = useLocation()
const { user } = useAdminGetSession()
const { users } = useAdminUsers()
const { store } = useAdminStore()
const { isFeatureEnabled } = useFeatureFlag()
const isEnabled = useMemo(() => {
return isFeatureEnabled("analytics")
}, [isFeatureEnabled])
const analytics = useMemo(() => {
if (!config || !isEnabled) {
return null // Don't initialize analytics if not enabled or the user's preferences are not loaded yet
}
if (config.opt_out) {
return null // Don't initialize if user has opted out
}
return AnalyticsBrowser.load({ writeKey })
}, [config, writeKey, isEnabled])
useEffect(() => {
if (!analytics || !config || !user || !store) {
return
}
analytics.identify(user.id, {
store: store.name,
})
}, [config, analytics, user, store])
const askPermission = useMemo(() => {
if (submittingConfig) {
return true
}
if (!isEnabled || !user) {
return false // Don't ask for permission if feature is not enabled
}
return !config && !isLoading
}, [config, isLoading, isEnabled, user, submittingConfig])
/**
* Ensure that the focus modal is animated smoothly.
*/
const animateIn = useDebounce(askPermission, 1000)
const track = useCallback(
(event: Event, properties?: Record<string, unknown>) => {
if (!analytics) {
// If analytics is not initialized, then we return early
return
}
analytics.track(event, properties)
},
[analytics]
)
const trackNumberOfUsers = useCallback(
(properties: TrackCountPayload) => {
track("numUsers", properties)
},
[track]
)
const trackStoreName = useCallback(
(properties: TrackStoreNamePayload) => {
track("storeName", properties)
},
[track]
)
const trackNumberOfProducts = (properties: TrackCountPayload) => {
track("numProducts", properties)
}
const trackNumberOfOrders = (properties: TrackCountPayload) => {
track("numOrders", properties)
}
const trackRegions = (properties: TrackRegionsPayload) => {
track("regions", properties)
}
const trackCurrencies = (properties: TrackCurrenciesPayload) => {
track("currencies", properties)
}
const trackNumberOfDiscounts = (properties: TrackCountPayload) => {
track("numDiscounts", properties)
}
// Track number of users
useEffect(() => {
if (users) {
trackNumberOfUsers({ count: users.length })
}
}, [users, trackNumberOfUsers])
// Track store name
useEffect(() => {
if (store) {
trackStoreName({ name: store.name })
}
}, [store, trackStoreName])
// Track pages visited when location changes
useEffect(() => {
if (!analytics) {
return
}
analytics.page()
}, [location])
return (
<AnalyticsContext.Provider
value={{
trackRegions,
trackCurrencies,
trackNumberOfOrders,
trackNumberOfProducts,
trackNumberOfDiscounts,
setSubmittingConfig,
}}
>
{askPermission && (
<Fade isVisible={animateIn} isFullScreen={true}>
<AnalyticsPreferencesModal />
</Fade>
)}
{children}
</AnalyticsContext.Provider>
)
}
type TrackCurrenciesPayload = {
used_currencies: string[]
}
type TrackStoreNamePayload = {
name: string
}
type TrackCountPayload = {
count: number
}
type TrackRegionsPayload = {
regions: string[]
count: number
}
export const useAnalytics = () => {
const context = useContext(AnalyticsContext)
if (!context) {
throw new Error("useAnalytics must be used within a AnalyticsProvider")
}
return context
}

View File

@@ -0,0 +1,73 @@
import { useAdminGetSession, useAdminStore } from "medusa-react"
import React, {
PropsWithChildren,
useContext,
useEffect,
useState,
} from "react"
const defaultFeatureFlagContext: {
featureToggleList: Record<string, boolean>
isFeatureEnabled: (flag: string) => boolean
} = {
featureToggleList: {},
isFeatureEnabled: function (flag): boolean {
return !!this.featureToggleList[flag]
},
}
const FeatureFlagContext = React.createContext(defaultFeatureFlagContext)
export const FeatureFlagProvider = ({ children }: PropsWithChildren) => {
const { user, isLoading } = useAdminGetSession()
const [featureFlags, setFeatureFlags] = useState<
{ key: string; value: boolean }[]
>([])
const { store, isFetching } = useAdminStore()
useEffect(() => {
if (
isFetching ||
!store ||
(!user && !isLoading) ||
!store["feature_flags"]?.length
) {
return
}
setFeatureFlags([
...store["feature_flags"],
...store["modules"].map((module) => ({
key: module.module,
value: true,
})),
])
}, [isFetching, store, user, isLoading])
const featureToggleList = featureFlags.reduce(
(acc, flag) => ({ ...acc, [flag.key]: flag.value }),
{} as Record<string, boolean>
)
const isFeatureEnabled = (flag: string) => !!featureToggleList[flag]
return (
<FeatureFlagContext.Provider
value={{ isFeatureEnabled, featureToggleList }}
>
{children}
</FeatureFlagContext.Provider>
)
}
export const useFeatureFlag = () => {
const context = useContext(FeatureFlagContext)
if (!context) {
throw new Error("useFeatureFlag must be used within a FeatureFlagProvider")
}
return context
}

View File

@@ -0,0 +1,17 @@
import { MedusaProvider as Provider } from "medusa-react"
import { PropsWithChildren } from "react"
import { MEDUSA_BACKEND_URL } from "../constants/medusa-backend-url"
import { queryClient } from "../constants/query-client"
export const MedusaProvider = ({ children }: PropsWithChildren) => {
return (
<Provider
queryClientProviderProps={{
client: queryClient,
}}
baseUrl={MEDUSA_BACKEND_URL}
>
{children}
</Provider>
)
}

View File

@@ -0,0 +1,99 @@
import { AdminGetBatchParams, BatchJob } from "@medusajs/medusa"
import { useAdminBatchJobs } from "medusa-react"
import React, { PropsWithChildren, useCallback, useMemo } from "react"
type IPollingContext = {
batchJobs?: BatchJob[]
hasPollingError?: boolean
resetInterval: () => Promise<void>
refetch: () => void
}
const PollingContext = React.createContext<IPollingContext | null>(null)
const oneMonthAgo = new Date(new Date().setMonth(new Date().getMonth() - 1))
oneMonthAgo.setHours(0, 0, 0, 0)
/**
* Intervals for refetching batch jobs in seconds.
*/
const INTERVALS = [2, 5, 10, 15, 30, 60]
/**
* Function factory for creating deduplicating timer object.
* @param start - Initial starting point in the intervals array.
*/
const createDedupingTimer = (start: number) => {
let deduplicationThreshold = Date.now()
return {
current: start,
register() {
deduplicationThreshold = Date.now()
const currentInd = INTERVALS.findIndex((s) => s === this.current)
this.current = INTERVALS[Math.min(INTERVALS.length - 1, currentInd + 1)]
},
isEnabled() {
return Date.now() >= deduplicationThreshold
},
reset() {
deduplicationThreshold = Date.now()
this.current = INTERVALS[0]
},
}
}
const Timer = createDedupingTimer(INTERVALS[0])
/**
* Batch job polling context provides batch jobs to the context.
* Jobs are refreshed with nonlinear intervals.
*/
export const PollingProvider = ({ children }: PropsWithChildren) => {
const {
batch_jobs: batchJobs,
isError: hasPollingError,
refetch,
} = useAdminBatchJobs(
{
created_at: { gte: oneMonthAgo },
failed_at: null,
} as AdminGetBatchParams,
{
refetchOnWindowFocus: true,
// @ts-ignore: Type is bugged in @tanstack/react-query
refetchInterval: Timer.current * 1000, // this is scheduling refetches
enabled: Timer.isEnabled(), // this is only preventing refetches in between scheduled deadlines
onSettled: Timer.register.bind(Timer),
}
)
const resetInterval = useCallback(async () => {
Timer.reset()
await refetch()
}, [refetch])
const value = useMemo(
() => ({
batchJobs,
hasPollingError,
resetInterval,
refetch,
}),
[refetch, batchJobs, hasPollingError, resetInterval]
)
return (
<PollingContext.Provider value={value}>{children}</PollingContext.Provider>
)
}
export const usePolling = () => {
const context = React.useContext(PollingContext)
if (!context) {
throw new Error("usePolling must be used within a PollingProvider")
}
return context
}

View File

@@ -0,0 +1,23 @@
import { PropsWithChildren } from "react"
import { LayeredModalProvider } from "../components/molecules/modal/layered-modal"
import { SteppedProvider } from "../components/molecules/modal/stepped-modal"
import { FeatureFlagProvider } from "./feature-flag-provider"
import { MedusaProvider } from "./medusa-provider"
import { PollingProvider } from "./polling-provider"
/**
* This component wraps all providers into a single component.
*/
export const Providers = ({ children }: PropsWithChildren) => {
return (
<MedusaProvider>
<FeatureFlagProvider>
<PollingProvider>
<SteppedProvider>
<LayeredModalProvider>{children}</LayeredModalProvider>
</SteppedProvider>
</PollingProvider>
</FeatureFlagProvider>
</MedusaProvider>
)
}

View File

@@ -0,0 +1,32 @@
import React, { createContext, ReactNode } from "react"
type SkeletonContextType = {
isLoading?: boolean
}
const SkeletonContext = createContext<SkeletonContextType>({
isLoading: false,
})
type Props = {
children?: ReactNode
isLoading?: boolean
}
export const SkeletonProvider = ({ children, isLoading }: Props) => {
return (
<SkeletonContext.Provider
value={{
isLoading,
}}
>
{children}
</SkeletonContext.Provider>
)
}
export const useSkeleton = () => {
const { isLoading } = React.useContext(SkeletonContext)
return { isLoading }
}