feat(admin,admin-ui,medusa): Add Medusa Admin plugin (#3334)
This commit is contained in:
committed by
GitHub
parent
d6b1ad1ccd
commit
40de54b010
212
packages/admin-ui/ui/src/providers/analytics-provider.tsx
Normal file
212
packages/admin-ui/ui/src/providers/analytics-provider.tsx
Normal 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
|
||||
}
|
||||
73
packages/admin-ui/ui/src/providers/feature-flag-provider.tsx
Normal file
73
packages/admin-ui/ui/src/providers/feature-flag-provider.tsx
Normal 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
|
||||
}
|
||||
17
packages/admin-ui/ui/src/providers/medusa-provider.tsx
Normal file
17
packages/admin-ui/ui/src/providers/medusa-provider.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
99
packages/admin-ui/ui/src/providers/polling-provider.tsx
Normal file
99
packages/admin-ui/ui/src/providers/polling-provider.tsx
Normal 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
|
||||
}
|
||||
23
packages/admin-ui/ui/src/providers/providers.tsx
Normal file
23
packages/admin-ui/ui/src/providers/providers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
packages/admin-ui/ui/src/providers/skeleton-provider.tsx
Normal file
32
packages/admin-ui/ui/src/providers/skeleton-provider.tsx
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user