mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-24 11:22:19 +03:00
Replace custom data loading with react-query (#17537)
refs https://github.com/TryGhost/Product/issues/3349
This commit is contained in:
parent
841e52ccfe
commit
55d243f470
17
.vscode/settings.json
vendored
17
.vscode/settings.json
vendored
@ -22,5 +22,20 @@
|
|||||||
},
|
},
|
||||||
"tailwindCSS.experimental.classRegex": [
|
"tailwindCSS.experimental.classRegex": [
|
||||||
["clsx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
["clsx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||||
]
|
],
|
||||||
|
"workbench.colorCustomizations": {
|
||||||
|
"editorIndentGuide.background": "#00000000",
|
||||||
|
"[Material Theme Palenight High Contrast]": {
|
||||||
|
"tab.activeBorder": "#00000000",
|
||||||
|
"tab.inactiveBackground": "#1b1e2b",
|
||||||
|
"activityBar.activeBorder": "#a6accd",
|
||||||
|
"editorGroupHeader.tabsBackground": "#1b1e2b",
|
||||||
|
"tab.border": "#00000000",
|
||||||
|
"sideBarSectionHeader.border": "#00000000",
|
||||||
|
"terminal.border": "#a6accd"
|
||||||
|
},
|
||||||
|
"activityBar.background": "#0C3429",
|
||||||
|
"titleBar.activeBackground": "#114939",
|
||||||
|
"titleBar.activeForeground": "#F4FDFA"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -46,6 +46,7 @@
|
|||||||
"@dnd-kit/core": "6.0.8",
|
"@dnd-kit/core": "6.0.8",
|
||||||
"@dnd-kit/sortable": "7.0.2",
|
"@dnd-kit/sortable": "7.0.2",
|
||||||
"@ebay/nice-modal-react": "1.2.10",
|
"@ebay/nice-modal-react": "1.2.10",
|
||||||
|
"@tanstack/react-query": "4.29.25",
|
||||||
"@tryghost/timezone-data": "0.3.0",
|
"@tryghost/timezone-data": "0.3.0",
|
||||||
"clsx": "2.0.0",
|
"clsx": "2.0.0",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
|
@ -5,57 +5,61 @@ import NiceModal from '@ebay/nice-modal-react';
|
|||||||
import RoutingProvider from './components/providers/RoutingProvider';
|
import RoutingProvider from './components/providers/RoutingProvider';
|
||||||
import Settings from './components/Settings';
|
import Settings from './components/Settings';
|
||||||
import Sidebar from './components/Sidebar';
|
import Sidebar from './components/Sidebar';
|
||||||
import {GlobalDirtyStateProvider} from './hooks/useGlobalDirtyState';
|
import { GlobalDirtyStateProvider } from './hooks/useGlobalDirtyState';
|
||||||
import {OfficialTheme} from './models/themes';
|
import { OfficialTheme } from './models/themes';
|
||||||
import {ServicesProvider} from './components/providers/ServiceProvider';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import {Toaster} from 'react-hot-toast';
|
import { ServicesProvider } from './components/providers/ServiceProvider';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
ghostVersion: string;
|
ghostVersion: string;
|
||||||
officialThemes: OfficialTheme[];
|
officialThemes: OfficialTheme[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
function App({ghostVersion, officialThemes}: AppProps) {
|
function App({ghostVersion, officialThemes}: AppProps) {
|
||||||
return (
|
return (
|
||||||
<ServicesProvider ghostVersion={ghostVersion} officialThemes={officialThemes}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<DataProvider>
|
<ServicesProvider ghostVersion={ghostVersion} officialThemes={officialThemes}>
|
||||||
<RoutingProvider>
|
<DataProvider>
|
||||||
<GlobalDirtyStateProvider>
|
<RoutingProvider>
|
||||||
<div className="admin-x-settings h-[100vh] w-full overflow-y-auto" id="admin-x-root" style={{
|
<GlobalDirtyStateProvider>
|
||||||
height: '100vh',
|
<div className="admin-x-settings h-[100vh] w-full overflow-y-auto" id="admin-x-root" style={{
|
||||||
width: '100%'
|
height: '100vh',
|
||||||
}}
|
width: '100%'
|
||||||
>
|
}}
|
||||||
<Toaster />
|
>
|
||||||
<NiceModal.Provider>
|
<Toaster />
|
||||||
<div className='fixed left-6 top-4 z-20'>
|
<NiceModal.Provider>
|
||||||
<ExitSettingsButton />
|
<div className='fixed left-6 top-4 z-20'>
|
||||||
</div>
|
<ExitSettingsButton />
|
||||||
|
|
||||||
{/* Main container */}
|
|
||||||
<div className="mx-auto flex max-w-[1080px] flex-col px-[5vmin] py-[12vmin] md:flex-row md:items-start md:gap-x-10 md:py-[8vmin]" id="admin-x-settings-content">
|
|
||||||
|
|
||||||
{/* Sidebar */}
|
|
||||||
<div className="relative z-20 min-w-[260px] grow-0 md:fixed md:top-[8vmin] md:basis-[260px]">
|
|
||||||
<div className='h-[84px]'>
|
|
||||||
<Heading>Settings</Heading>
|
|
||||||
</div>
|
|
||||||
<div className="relative mt-[-32px] w-[260px] overflow-x-hidden after:absolute after:inset-x-0 after:top-0 after:block after:h-[40px] after:bg-gradient-to-b after:from-white after:to-transparent after:content-['']">
|
|
||||||
<Sidebar />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex-auto pt-[3vmin] md:ml-[300px] md:pt-[85px]">
|
{/* Main container */}
|
||||||
<div className='pointer-events-none fixed inset-x-0 top-0 z-[5] h-[130px] bg-gradient-to-t from-transparent to-white to-60%'></div>
|
<div className="mx-auto flex max-w-[1080px] flex-col px-[5vmin] py-[12vmin] md:flex-row md:items-start md:gap-x-10 md:py-[8vmin]" id="admin-x-settings-content">
|
||||||
<Settings />
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="relative z-20 min-w-[260px] grow-0 md:fixed md:top-[8vmin] md:basis-[260px]">
|
||||||
|
<div className='h-[84px]'>
|
||||||
|
<Heading>Settings</Heading>
|
||||||
|
</div>
|
||||||
|
<div className="relative mt-[-32px] w-[260px] overflow-x-hidden after:absolute after:inset-x-0 after:top-0 after:block after:h-[40px] after:bg-gradient-to-b after:from-white after:to-transparent after:content-['']">
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex-auto pt-[3vmin] md:ml-[300px] md:pt-[85px]">
|
||||||
|
<div className='pointer-events-none fixed inset-x-0 top-0 z-[5] h-[130px] bg-gradient-to-t from-transparent to-white to-60%'></div>
|
||||||
|
<Settings />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NiceModal.Provider>
|
||||||
</NiceModal.Provider>
|
</div>
|
||||||
</div>
|
</GlobalDirtyStateProvider>
|
||||||
</GlobalDirtyStateProvider>
|
</RoutingProvider>
|
||||||
</RoutingProvider>
|
</DataProvider>
|
||||||
</DataProvider>
|
</ServicesProvider>
|
||||||
</ServicesProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,11 +1,12 @@
|
|||||||
import TaskList from './Tasklist';
|
import TaskList from './Tasklist';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
import * as TaskStories from './Task.stories';
|
import * as TaskStories from './Task.stories';
|
||||||
|
|
||||||
const story = {
|
const story = {
|
||||||
component: TaskList,
|
component: TaskList,
|
||||||
title: 'Experimental / Task List',
|
title: 'Experimental / Task List',
|
||||||
decorators: [(_story: any) => <div style={{padding: '3rem'}}>{_story()}</div>],
|
decorators: [(_story: () => ReactNode) => <div style={{padding: '3rem'}}>{_story()}</div>],
|
||||||
tags: ['autodocs']
|
tags: ['autodocs']
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -43,4 +44,4 @@ export const Empty = {
|
|||||||
...Loading.args,
|
...Loading.args,
|
||||||
loading: false
|
loading: false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, {useEffect, useState} from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
interface UseDynamicSVGImportOptions {
|
interface UseDynamicSVGImportOptions {
|
||||||
@ -27,9 +27,13 @@ function useDynamicSVGImport(
|
|||||||
).ReactComponent;
|
).ReactComponent;
|
||||||
setSvgComponent(() => SvgIcon);
|
setSvgComponent(() => SvgIcon);
|
||||||
onCompleted?.(name, SvgIcon);
|
onCompleted?.(name, SvgIcon);
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
onError?.(err);
|
if (err instanceof Error) {
|
||||||
setError(() => err);
|
onError?.(err);
|
||||||
|
setError(err);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(() => false);
|
setLoading(() => false);
|
||||||
}
|
}
|
||||||
@ -103,4 +107,4 @@ const Icon: React.FC<IconProps> = ({name, size = 'md', colorClass = 'text-black'
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Icon;
|
export default Icon;
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import * as ListItemStories from './ListItem.stories';
|
import * as ListItemStories from './ListItem.stories';
|
||||||
import List from './List';
|
import List from './List';
|
||||||
import ListItem from './ListItem';
|
import ListItem from './ListItem';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Global / List',
|
title: 'Global / List',
|
||||||
@ -29,7 +30,7 @@ export const Default: Story = {
|
|||||||
children: listItems,
|
children: listItems,
|
||||||
hint: 'And here is a hint for the whole list'
|
hint: 'And here is a hint for the whole list'
|
||||||
},
|
},
|
||||||
decorators: [(_story: any) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)]
|
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)]
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageLevel: Story = {
|
export const PageLevel: Story = {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import Avatar from './Avatar';
|
import Avatar from './Avatar';
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
@ -9,7 +9,7 @@ const meta = {
|
|||||||
title: 'Global / List / List Item',
|
title: 'Global / List / List Item',
|
||||||
component: ListItem,
|
component: ListItem,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)],
|
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)],
|
||||||
argTypes: {
|
argTypes: {
|
||||||
title: {control: 'text'},
|
title: {control: 'text'},
|
||||||
detail: {control: 'text'}
|
detail: {control: 'text'}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { ReactNode } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import Button from './Button';
|
import Button from './Button';
|
||||||
import Menu from './Menu';
|
import Menu from './Menu';
|
||||||
@ -7,7 +8,7 @@ const meta = {
|
|||||||
title: 'Global / Menu',
|
title: 'Global / Menu',
|
||||||
component: Menu,
|
component: Menu,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any) => (<div style={{maxWidth: '100px', margin: '0 auto', padding: '100px 0 200px'}}>{_story()}</div>)]
|
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '100px', margin: '0 auto', padding: '100px 0 200px'}}>{_story()}</div>)]
|
||||||
} satisfies Meta<typeof Menu>;
|
} satisfies Meta<typeof Menu>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
@ -59,4 +60,4 @@ export const LongLabels: Story = {
|
|||||||
items: longItems,
|
items: longItems,
|
||||||
position: 'right'
|
position: 'right'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import {useArgs} from '@storybook/preview-api';
|
import { useArgs } from '@storybook/preview-api';
|
||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import SortableList, {SortableListProps} from './SortableList';
|
import SortableList, { SortableListProps } from './SortableList';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {arrayMove} from '@dnd-kit/sortable';
|
import { arrayMove } from '@dnd-kit/sortable';
|
||||||
import {useState} from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
const Wrapper = (props: SortableListProps<any> & {updateArgs: (args: Partial<SortableListProps<any>>) => void}) => {
|
const Wrapper = (props: SortableListProps<{id: string}> & {updateArgs: (args: Partial<SortableListProps<{id: string}>>) => void}) => {
|
||||||
// Seems like Storybook recreates items on every render, so we need to keep our own state
|
// Seems like Storybook recreates items on every render, so we need to keep our own state
|
||||||
const [items, setItems] = useState(props.items);
|
const [items, setItems] = useState(props.items);
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { ReactNode } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import StickyFooter from './StickyFooter';
|
import StickyFooter from './StickyFooter';
|
||||||
|
|
||||||
@ -6,7 +7,7 @@ const meta = {
|
|||||||
title: 'Global / Sticky Footer',
|
title: 'Global / Sticky Footer',
|
||||||
component: StickyFooter,
|
component: StickyFooter,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any) => (
|
decorators: [(_story: () => ReactNode) => (
|
||||||
<div style={{
|
<div style={{
|
||||||
maxWidth: '600px',
|
maxWidth: '600px',
|
||||||
margin: '0 auto 80px',
|
margin: '0 auto 80px',
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { ReactNode } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import Table from './Table';
|
import Table from './Table';
|
||||||
import TableCell from './TableCell';
|
import TableCell from './TableCell';
|
||||||
@ -34,5 +35,5 @@ export const Default: Story = {
|
|||||||
args: {
|
args: {
|
||||||
children: tableRows
|
children: tableRows
|
||||||
},
|
},
|
||||||
decorators: [(_story: any) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)]
|
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)]
|
||||||
};
|
};
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { ReactNode } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import ToastContainer from './ToastContainer';
|
import ToastContainer from './ToastContainer';
|
||||||
import {Toaster} from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Global / Toast',
|
title: 'Global / Toast',
|
||||||
component: ToastContainer,
|
component: ToastContainer,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any) => (
|
decorators: [(_story: () => ReactNode) => (
|
||||||
<>
|
<>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
{_story()}
|
{_story()}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import Icon from './Icon';
|
import Icon from './Icon';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {ToastOptions, toast} from 'react-hot-toast';
|
import { Toast as HotToast, ToastOptions, toast } from 'react-hot-toast';
|
||||||
|
|
||||||
export type ToastType = 'neutral' | 'success' | 'error' | 'pageError';
|
export type ToastType = 'neutral' | 'success' | 'error' | 'pageError';
|
||||||
|
|
||||||
@ -13,7 +13,7 @@ export interface ShowToastProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ToastProps {
|
interface ToastProps {
|
||||||
t: any;
|
t: HotToast;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Can be a name of an icon from the icon library or a react component
|
* Can be a name of an icon from the icon library or a react component
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { ReactNode } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import DesktopChrome from './DesktopChrome';
|
import DesktopChrome from './DesktopChrome';
|
||||||
|
|
||||||
@ -6,7 +7,7 @@ const meta = {
|
|||||||
title: 'Global / Chrome / Desktop Chrome',
|
title: 'Global / Chrome / Desktop Chrome',
|
||||||
component: DesktopChrome,
|
component: DesktopChrome,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any) => (<div style={{padding: '40px', backgroundColor: '#efefef', display: 'flex', justifyContent: 'center'}}>{_story()}</div>)]
|
decorators: [(_story: () => ReactNode) => (<div style={{padding: '40px', backgroundColor: '#efefef', display: 'flex', justifyContent: 'center'}}>{_story()}</div>)]
|
||||||
} satisfies Meta<typeof DesktopChrome>;
|
} satisfies Meta<typeof DesktopChrome>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { ReactNode } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import MobileChrome from './MobileChrome';
|
import MobileChrome from './MobileChrome';
|
||||||
|
|
||||||
@ -6,7 +7,7 @@ const meta = {
|
|||||||
title: 'Global / Chrome / Mobile Chrome',
|
title: 'Global / Chrome / Mobile Chrome',
|
||||||
component: MobileChrome,
|
component: MobileChrome,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any) => (<div style={{padding: '40px', backgroundColor: '#efefef', display: 'flex', justifyContent: 'center'}}>{_story()}</div>)]
|
decorators: [(_story: () => ReactNode) => (<div style={{padding: '40px', backgroundColor: '#efefef', display: 'flex', justifyContent: 'center'}}>{_story()}</div>)]
|
||||||
} satisfies Meta<typeof MobileChrome>;
|
} satisfies Meta<typeof MobileChrome>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { ReactNode } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import Checkbox from './Checkbox';
|
import Checkbox from './Checkbox';
|
||||||
|
|
||||||
@ -6,7 +7,7 @@ const meta = {
|
|||||||
title: 'Global / Form / Checkbox',
|
title: 'Global / Form / Checkbox',
|
||||||
component: Checkbox,
|
component: Checkbox,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
|
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
|
||||||
argTypes: {
|
argTypes: {
|
||||||
hint: {
|
hint: {
|
||||||
control: 'text'
|
control: 'text'
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, {ReactNode, Suspense, useCallback, useMemo} from 'react';
|
import React, { ReactNode, Suspense, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
export interface HtmlEditorProps {
|
export interface HtmlEditorProps {
|
||||||
value?: string
|
value?: string
|
||||||
@ -10,12 +10,14 @@ export interface HtmlEditorProps {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
'@tryghost/koenig-lexical': any;
|
'@tryghost/koenig-lexical': any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchKoenig = function ({editorUrl, editorVersion}: { editorUrl: string; editorVersion: string; }) {
|
const fetchKoenig = function ({editorUrl, editorVersion}: { editorUrl: string; editorVersion: string; }) {
|
||||||
let status = 'pending';
|
let status = 'pending';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
let response: any;
|
let response: any;
|
||||||
|
|
||||||
const fetchPackage = async () => {
|
const fetchPackage = async () => {
|
||||||
@ -64,7 +66,7 @@ class ErrorHandler extends React.Component<{ children: ReactNode }> {
|
|||||||
return {hasError: true};
|
return {hasError: true};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: any, errorInfo: any) {
|
componentDidCatch(error: unknown, errorInfo: unknown) {
|
||||||
console.error(error, errorInfo); // eslint-disable-line
|
console.error(error, errorInfo); // eslint-disable-line
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,7 +89,7 @@ const KoenigWrapper: React.FC<HtmlEditorProps & { editor: EditorResource }> = ({
|
|||||||
placeholder,
|
placeholder,
|
||||||
nodes
|
nodes
|
||||||
}) => {
|
}) => {
|
||||||
const onError = useCallback((error: any) => {
|
const onError = useCallback((error: unknown) => {
|
||||||
// ensure we're still showing errors in development
|
// ensure we're still showing errors in development
|
||||||
console.error(error); // eslint-disable-line
|
console.error(error); // eslint-disable-line
|
||||||
|
|
||||||
@ -105,6 +107,7 @@ const KoenigWrapper: React.FC<HtmlEditorProps & { editor: EditorResource }> = ({
|
|||||||
// don't rethrow, Lexical will attempt to gracefully recover
|
// don't rethrow, Lexical will attempt to gracefully recover
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const koenig = useMemo(() => new Proxy({} as { [key: string]: any }, {
|
const koenig = useMemo(() => new Proxy({} as { [key: string]: any }, {
|
||||||
get: (_target, prop) => {
|
get: (_target, prop) => {
|
||||||
return editor.read()[prop];
|
return editor.read()[prop];
|
||||||
|
@ -1,14 +1,16 @@
|
|||||||
import Heading from '../Heading';
|
import Heading from '../Heading';
|
||||||
import Hint from '../Hint';
|
import Hint from '../Hint';
|
||||||
import HtmlEditor, {HtmlEditorProps} from './HtmlEditor';
|
import HtmlEditor, { HtmlEditorProps } from './HtmlEditor';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export type EditorConfig = { editor: { url: string; version: string; } }
|
||||||
|
|
||||||
export type HtmlFieldProps = HtmlEditorProps & {
|
export type HtmlFieldProps = HtmlEditorProps & {
|
||||||
/**
|
/**
|
||||||
* Should be passed the Ghost instance config to get the editor JS URL
|
* Should be passed the Ghost instance config to get the editor JS URL
|
||||||
*/
|
*/
|
||||||
config: { editor: { url: string; version: string; } };
|
config: EditorConfig;
|
||||||
title?: string;
|
title?: string;
|
||||||
hideTitle?: boolean;
|
hideTitle?: boolean;
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { ReactNode } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import ImageUpload from './ImageUpload';
|
import ImageUpload from './ImageUpload';
|
||||||
|
|
||||||
@ -6,7 +7,7 @@ const meta = {
|
|||||||
title: 'Global / Form / Image upload',
|
title: 'Global / Form / Image upload',
|
||||||
component: ImageUpload,
|
component: ImageUpload,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)]
|
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '600px'}}>{_story()}</div>)]
|
||||||
} satisfies Meta<typeof ImageUpload>;
|
} satisfies Meta<typeof ImageUpload>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
@ -48,4 +49,4 @@ export const ImageUploaded: Story = {
|
|||||||
alert('Delete image');
|
alert('Delete image');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { ReactNode } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import Radio, {RadioOption} from './Radio';
|
import Radio, { RadioOption } from './Radio';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Global / Form / Radio',
|
title: 'Global / Form / Radio',
|
||||||
component: Radio,
|
component: Radio,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
|
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
|
||||||
argTypes: {
|
argTypes: {
|
||||||
hint: {
|
hint: {
|
||||||
control: 'text'
|
control: 'text'
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import {useArgs} from '@storybook/preview-api';
|
import { ReactNode } from 'react';
|
||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { useArgs } from '@storybook/preview-api';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import Select, {SelectOption} from './Select';
|
import Select, { SelectOption } from './Select';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Global / Form / Select',
|
title: 'Global / Form / Select',
|
||||||
component: Select,
|
component: Select,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
|
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
|
||||||
argTypes: {
|
argTypes: {
|
||||||
hint: {
|
hint: {
|
||||||
control: 'text'
|
control: 'text'
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {useArgs} from '@storybook/preview-api';
|
import { ReactNode } from 'react';
|
||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { useArgs } from '@storybook/preview-api';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import TextArea from './TextArea';
|
import TextArea from './TextArea';
|
||||||
|
|
||||||
@ -7,7 +8,7 @@ const meta = {
|
|||||||
title: 'Global / Form / Textarea',
|
title: 'Global / Form / Textarea',
|
||||||
component: TextArea,
|
component: TextArea,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
|
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
|
||||||
argTypes: {
|
argTypes: {
|
||||||
hint: {
|
hint: {
|
||||||
control: 'text'
|
control: 'text'
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import {useArgs} from '@storybook/preview-api';
|
import { ReactNode } from 'react';
|
||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { useArgs } from '@storybook/preview-api';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import TextField from './TextField';
|
import TextField from './TextField';
|
||||||
|
|
||||||
@ -7,7 +8,7 @@ const meta = {
|
|||||||
title: 'Global / Form / Textfield',
|
title: 'Global / Form / Textfield',
|
||||||
component: TextField,
|
component: TextField,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
|
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)],
|
||||||
argTypes: {
|
argTypes: {
|
||||||
hint: {
|
hint: {
|
||||||
control: 'text'
|
control: 'text'
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { ReactNode } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import Toggle from './Toggle';
|
import Toggle from './Toggle';
|
||||||
|
|
||||||
@ -6,7 +7,7 @@ const meta = {
|
|||||||
title: 'Global / Form / Toggle',
|
title: 'Global / Form / Toggle',
|
||||||
component: Toggle,
|
component: Toggle,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)]
|
decorators: [(_story: () => ReactNode) => (<div style={{maxWidth: '400px'}}>{_story()}</div>)]
|
||||||
} satisfies Meta<typeof Toggle>;
|
} satisfies Meta<typeof Toggle>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { ReactNode } from 'react';
|
||||||
|
import type { Meta, StoryContext, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import ConfirmationModal from './ConfirmationModal';
|
import ConfirmationModal from './ConfirmationModal';
|
||||||
import ConfirmationModalContainer from './ConfirmationModalContainer';
|
import ConfirmationModalContainer from './ConfirmationModalContainer';
|
||||||
@ -8,7 +9,7 @@ const meta = {
|
|||||||
title: 'Global / Modal / Confirmation Modal',
|
title: 'Global / Modal / Confirmation Modal',
|
||||||
component: ConfirmationModal,
|
component: ConfirmationModal,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any, context: any) => (
|
decorators: [(_story: () => ReactNode, context: StoryContext) => (
|
||||||
<NiceModal.Provider>
|
<NiceModal.Provider>
|
||||||
<ConfirmationModalContainer {...context.args} />
|
<ConfirmationModalContainer {...context.args} />
|
||||||
</NiceModal.Provider>
|
</NiceModal.Provider>
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { ReactNode } from 'react';
|
||||||
|
import type { Meta, StoryContext, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
import ModalContainer from './ModalContainer';
|
import ModalContainer from './ModalContainer';
|
||||||
@ -9,7 +10,7 @@ const meta = {
|
|||||||
title: 'Global / Modal',
|
title: 'Global / Modal',
|
||||||
component: Modal,
|
component: Modal,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any, context: any) => (
|
decorators: [(_story: () => ReactNode, context: StoryContext) => (
|
||||||
<NiceModal.Provider>
|
<NiceModal.Provider>
|
||||||
<ModalContainer {...context.args} />
|
<ModalContainer {...context.args} />
|
||||||
</NiceModal.Provider>
|
</NiceModal.Provider>
|
||||||
@ -166,4 +167,4 @@ export const Dirty: Story = {
|
|||||||
title: 'Dirty modal',
|
title: 'Dirty modal',
|
||||||
children: <p>Simulates if there were unsaved changes of a form. Click on Cancel</p>
|
children: <p>Simulates if there were unsaved changes of a form. Click on Cancel</p>
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,16 +1,17 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { ReactNode } from 'react';
|
||||||
|
import type { Meta, StoryContext, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import Heading from '../Heading';
|
import Heading from '../Heading';
|
||||||
import NiceModal from '@ebay/nice-modal-react';
|
import NiceModal from '@ebay/nice-modal-react';
|
||||||
import PreviewModal from './PreviewModal';
|
import PreviewModal from './PreviewModal';
|
||||||
import PreviewModalContainer from './PreviewModalContainer';
|
import PreviewModalContainer from './PreviewModalContainer';
|
||||||
import {Tab} from '../TabView';
|
import { Tab } from '../TabView';
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
title: 'Global / Modal / Preview Modal',
|
title: 'Global / Modal / Preview Modal',
|
||||||
component: PreviewModal,
|
component: PreviewModal,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any, context: any) => (
|
decorators: [(_story: () => ReactNode, context: StoryContext) => (
|
||||||
<NiceModal.Provider>
|
<NiceModal.Provider>
|
||||||
<PreviewModalContainer {...context.args} />
|
<PreviewModalContainer {...context.args} />
|
||||||
</NiceModal.Provider>
|
</NiceModal.Provider>
|
||||||
@ -86,4 +87,4 @@ export const FullBleed: Story = {
|
|||||||
...Default.args,
|
...Default.args,
|
||||||
size: 'bleed'
|
size: 'bleed'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { ReactNode } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import * as SettingGroupContentStories from './SettingGroupContent.stories';
|
import * as SettingGroupContentStories from './SettingGroupContent.stories';
|
||||||
import * as SettingGroupHeaderStories from './SettingGroupHeader.stories';
|
import * as SettingGroupHeaderStories from './SettingGroupHeader.stories';
|
||||||
@ -12,7 +13,7 @@ const meta = {
|
|||||||
title: 'Settings / Setting Group',
|
title: 'Settings / Setting Group',
|
||||||
component: SettingGroup,
|
component: SettingGroup,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any) => <div style={{maxWidth: '780px'}}>{_story()}</div>],
|
decorators: [(_story: () => ReactNode) => <div style={{maxWidth: '780px'}}>{_story()}</div>],
|
||||||
argTypes: {
|
argTypes: {
|
||||||
description: {
|
description: {
|
||||||
control: 'text'
|
control: 'text'
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type {Meta, StoryObj} from '@storybook/react';
|
import { ReactNode } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
import * as SettingGroupStories from './SettingGroup.stories';
|
import * as SettingGroupStories from './SettingGroup.stories';
|
||||||
import SettingGroup from './SettingGroup';
|
import SettingGroup from './SettingGroup';
|
||||||
@ -8,7 +9,7 @@ const meta = {
|
|||||||
title: 'Settings / Setting Section',
|
title: 'Settings / Setting Section',
|
||||||
component: SettingSection,
|
component: SettingSection,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
decorators: [(_story: any) => <div style={{maxWidth: '780px'}}>{_story()}</div>]
|
decorators: [(_story: () => ReactNode) => <div style={{maxWidth: '780px'}}>{_story()}</div>]
|
||||||
} satisfies Meta<typeof SettingSection>;
|
} satisfies Meta<typeof SettingSection>;
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
@ -1,23 +1,11 @@
|
|||||||
import React, {useContext} from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import EmailSettings from './settings/email/EmailSettings';
|
import EmailSettings from './settings/email/EmailSettings';
|
||||||
import GeneralSettings from './settings/general/GeneralSettings';
|
import GeneralSettings from './settings/general/GeneralSettings';
|
||||||
import MembershipSettings from './settings/membership/MembershipSettings';
|
import MembershipSettings from './settings/membership/MembershipSettings';
|
||||||
import SiteSettings from './settings/site/SiteSettings';
|
import SiteSettings from './settings/site/SiteSettings';
|
||||||
import {SettingsContext} from './providers/SettingsProvider';
|
|
||||||
|
|
||||||
const Settings: React.FC = () => {
|
const Settings: React.FC = () => {
|
||||||
const {settings} = useContext(SettingsContext) || {};
|
|
||||||
|
|
||||||
// Show loader while settings is first fetched
|
|
||||||
if (!settings) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col items-center justify-center">
|
|
||||||
<div className="text-center text-2xl font-bold">Loading...</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<GeneralSettings />
|
<GeneralSettings />
|
||||||
|
@ -1,22 +1,82 @@
|
|||||||
import React from 'react';
|
import React, {ReactNode, createContext, useContext} from 'react';
|
||||||
import {RolesProvider} from './RolesProvider';
|
import {Config, Setting, SiteData, Tier, User} from '../../types/api';
|
||||||
import {SettingsProvider} from './SettingsProvider';
|
import {UserInvite, useBrowseInvites} from '../../utils/api/invites';
|
||||||
import {UsersProvider} from './UsersProvider';
|
import {useBrowseConfig} from '../../utils/api/config';
|
||||||
|
import {useBrowseSettings} from '../../utils/api/settings';
|
||||||
|
import {useBrowseSite} from '../../utils/api/site';
|
||||||
|
import {useBrowseTiers} from '../../utils/api/tiers';
|
||||||
|
import {useBrowseUsers, useCurrentUser} from '../../utils/api/users';
|
||||||
|
|
||||||
type DataProviderProps = {
|
type DataProviderProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface GlobalData {
|
||||||
|
settings: Setting[]
|
||||||
|
siteData: SiteData
|
||||||
|
config: Config
|
||||||
|
users: User[]
|
||||||
|
currentUser: User
|
||||||
|
invites: UserInvite[]
|
||||||
|
tiers: Tier[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const GlobalDataContext = createContext<GlobalData | undefined>(undefined);
|
||||||
|
|
||||||
|
const GlobalDataProvider = ({children}: { children: ReactNode }) => {
|
||||||
|
const settings = useBrowseSettings();
|
||||||
|
const site = useBrowseSite();
|
||||||
|
const config = useBrowseConfig();
|
||||||
|
const users = useBrowseUsers();
|
||||||
|
const currentUser = useCurrentUser();
|
||||||
|
const invites = useBrowseInvites();
|
||||||
|
const tiers = useBrowseTiers();
|
||||||
|
|
||||||
|
const requests = [
|
||||||
|
settings,
|
||||||
|
site,
|
||||||
|
config,
|
||||||
|
users,
|
||||||
|
currentUser,
|
||||||
|
invites,
|
||||||
|
tiers
|
||||||
|
];
|
||||||
|
|
||||||
|
const error = requests.map(request => request.error).find(Boolean);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requests.some(request => request.isLoading)) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center">
|
||||||
|
<div className="text-center text-2xl font-bold">Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <GlobalDataContext.Provider value={{
|
||||||
|
settings: settings.data!.settings,
|
||||||
|
siteData: site.data!.site,
|
||||||
|
config: config.data!.config,
|
||||||
|
users: users.data!.users,
|
||||||
|
currentUser: currentUser.data!,
|
||||||
|
invites: invites.data!.invites,
|
||||||
|
tiers: tiers.data!.tiers
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</GlobalDataContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGlobalData = () => useContext(GlobalDataContext)!;
|
||||||
|
|
||||||
const DataProvider: React.FC<DataProviderProps> = ({children}) => {
|
const DataProvider: React.FC<DataProviderProps> = ({children}) => {
|
||||||
return (
|
return (
|
||||||
<SettingsProvider>
|
<GlobalDataProvider>
|
||||||
<UsersProvider>
|
{children}
|
||||||
<RolesProvider>
|
</GlobalDataProvider>
|
||||||
{children}
|
|
||||||
</RolesProvider>
|
|
||||||
</UsersProvider>
|
|
||||||
</SettingsProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DataProvider;
|
export default DataProvider;
|
||||||
|
@ -1,53 +0,0 @@
|
|||||||
import React, {createContext, useContext, useEffect, useState} from 'react';
|
|
||||||
import {ServicesContext} from './ServiceProvider';
|
|
||||||
import {UserRole} from '../../types/api';
|
|
||||||
|
|
||||||
interface RolesContextProps {
|
|
||||||
roles: UserRole[];
|
|
||||||
assignableRoles: UserRole[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RolesProviderProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RolesContext = createContext<RolesContextProps>({
|
|
||||||
roles: [],
|
|
||||||
assignableRoles: []
|
|
||||||
});
|
|
||||||
|
|
||||||
const RolesProvider: React.FC<RolesProviderProps> = ({children}) => {
|
|
||||||
const {api} = useContext(ServicesContext);
|
|
||||||
const [roles, setRoles] = useState <UserRole[]> ([]);
|
|
||||||
const [assignableRoles, setAssignableRoles] = useState <UserRole[]> ([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchRoles = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const rolesData = await api.roles.browse();
|
|
||||||
const assignableRolesData = await api.roles.browse({
|
|
||||||
queryParams: {
|
|
||||||
permissions: 'assign'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
setRoles(rolesData.roles);
|
|
||||||
setAssignableRoles(assignableRolesData.roles);
|
|
||||||
} catch (error) {
|
|
||||||
// Log error in API
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchRoles();
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RolesContext.Provider value={{
|
|
||||||
roles,
|
|
||||||
assignableRoles
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</RolesContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export {RolesContext, RolesProvider};
|
|
@ -1,13 +1,12 @@
|
|||||||
import ChangeThemeModal from '../settings/site/ThemeModal';
|
import ChangeThemeModal from "../settings/site/ThemeModal";
|
||||||
import DesignModal from '../settings/site/DesignModal';
|
import DesignModal from "../settings/site/DesignModal";
|
||||||
import InviteUserModal from '../settings/general/InviteUserModal';
|
import InviteUserModal from "../settings/general/InviteUserModal";
|
||||||
import NavigationModal from '../settings/site/NavigationModal';
|
import NavigationModal from "../settings/site/NavigationModal";
|
||||||
import NiceModal from '@ebay/nice-modal-react';
|
import NiceModal from "@ebay/nice-modal-react";
|
||||||
import PortalModal from '../settings/membership/portal/PortalModal';
|
import PortalModal from "../settings/membership/portal/PortalModal";
|
||||||
import React, {createContext, useCallback, useContext, useEffect, useState} from 'react';
|
import React, { createContext, useCallback, useEffect, useState } from "react";
|
||||||
import StripeConnectModal from '../settings/membership/stripe/StripeConnectModal';
|
import StripeConnectModal from "../settings/membership/stripe/StripeConnectModal";
|
||||||
import TierDetailModal from '../settings/membership/tiers/TierDetailModal';
|
import TierDetailModal from "../settings/membership/tiers/TierDetailModal";
|
||||||
import {SettingsContext} from './SettingsProvider';
|
|
||||||
|
|
||||||
type RoutingContextProps = {
|
type RoutingContextProps = {
|
||||||
route: string;
|
route: string;
|
||||||
@ -18,11 +17,11 @@ type RoutingContextProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const RouteContext = createContext<RoutingContextProps>({
|
export const RouteContext = createContext<RoutingContextProps>({
|
||||||
route: '',
|
route: "",
|
||||||
scrolledRoute: '',
|
scrolledRoute: "",
|
||||||
yScroll: 0,
|
yScroll: 0,
|
||||||
updateRoute: () => {},
|
updateRoute: () => {},
|
||||||
updateScrolled: () => {}
|
updateScrolled: () => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
function getHashPath(urlPath: string | undefined) {
|
function getHashPath(urlPath: string | undefined) {
|
||||||
@ -42,9 +41,9 @@ function getHashPath(urlPath: string | undefined) {
|
|||||||
const scrollToSectionGroup = (pathName: string) => {
|
const scrollToSectionGroup = (pathName: string) => {
|
||||||
const element = document.getElementById(pathName);
|
const element = document.getElementById(pathName);
|
||||||
if (element) {
|
if (element) {
|
||||||
element.scrollIntoView({behavior: 'smooth'});
|
element.scrollIntoView({ behavior: "smooth" });
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleNavigation = (scroll: boolean = true) => {
|
const handleNavigation = (scroll: boolean = true) => {
|
||||||
// Get the hash from the URL
|
// Get the hash from the URL
|
||||||
@ -57,19 +56,19 @@ const handleNavigation = (scroll: boolean = true) => {
|
|||||||
const pathName = getHashPath(hash);
|
const pathName = getHashPath(hash);
|
||||||
|
|
||||||
if (pathName) {
|
if (pathName) {
|
||||||
if (pathName === 'design/edit/themes') {
|
if (pathName === "design/edit/themes") {
|
||||||
NiceModal.show(ChangeThemeModal);
|
NiceModal.show(ChangeThemeModal);
|
||||||
} else if (pathName === 'design/edit') {
|
} else if (pathName === "design/edit") {
|
||||||
NiceModal.show(DesignModal);
|
NiceModal.show(DesignModal);
|
||||||
} else if (pathName === 'navigation/edit') {
|
} else if (pathName === "navigation/edit") {
|
||||||
NiceModal.show(NavigationModal);
|
NiceModal.show(NavigationModal);
|
||||||
} else if (pathName === 'users/invite') {
|
} else if (pathName === "users/invite") {
|
||||||
NiceModal.show(InviteUserModal);
|
NiceModal.show(InviteUserModal);
|
||||||
} else if (pathName === 'portal/edit') {
|
} else if (pathName === "portal/edit") {
|
||||||
NiceModal.show(PortalModal);
|
NiceModal.show(PortalModal);
|
||||||
} else if (pathName === 'tiers/add') {
|
} else if (pathName === "tiers/add") {
|
||||||
NiceModal.show(TierDetailModal);
|
NiceModal.show(TierDetailModal);
|
||||||
} else if (pathName === 'stripe-connect') {
|
} else if (pathName === "stripe-connect") {
|
||||||
NiceModal.show(StripeConnectModal);
|
NiceModal.show(StripeConnectModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,31 +78,32 @@ const handleNavigation = (scroll: boolean = true) => {
|
|||||||
|
|
||||||
return pathName;
|
return pathName;
|
||||||
}
|
}
|
||||||
return '';
|
return "";
|
||||||
};
|
};
|
||||||
|
|
||||||
type RouteProviderProps = {
|
type RouteProviderProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RoutingProvider: React.FC<RouteProviderProps> = ({children}) => {
|
const RoutingProvider: React.FC<RouteProviderProps> = ({ children }) => {
|
||||||
const [route, setRoute] = useState<string>('');
|
const [route, setRoute] = useState<string>("");
|
||||||
const [yScroll, setYScroll] = useState(0);
|
const [yScroll, setYScroll] = useState(0);
|
||||||
const [scrolledRoute, setScrolledRoute] = useState<string>('');
|
const [scrolledRoute, setScrolledRoute] = useState<string>("");
|
||||||
|
|
||||||
const {settingsLoaded} = useContext(SettingsContext) || {};
|
const updateRoute = useCallback(
|
||||||
|
(newPath: string) => {
|
||||||
const updateRoute = useCallback((newPath: string) => {
|
if (newPath) {
|
||||||
if (newPath) {
|
if (newPath === route) {
|
||||||
if (newPath === route) {
|
scrollToSectionGroup(newPath);
|
||||||
scrollToSectionGroup(newPath);
|
} else {
|
||||||
|
window.location.hash = `/settings-x/${newPath}`;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
window.location.hash = `/settings-x/${newPath}`;
|
window.location.hash = `/settings-x`;
|
||||||
}
|
}
|
||||||
} else {
|
},
|
||||||
window.location.hash = `/settings-x`;
|
[route]
|
||||||
}
|
);
|
||||||
}, [route]);
|
|
||||||
|
|
||||||
const updateScrolled = useCallback((newPath: string) => {
|
const updateScrolled = useCallback((newPath: string) => {
|
||||||
setScrolledRoute(newPath);
|
setScrolledRoute(newPath);
|
||||||
@ -116,37 +116,37 @@ const RoutingProvider: React.FC<RouteProviderProps> = ({children}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const element = document.getElementById('admin-x-root');
|
const element = document.getElementById("admin-x-root");
|
||||||
const scrollPosition = element!.scrollTop;
|
const scrollPosition = element!.scrollTop;
|
||||||
setYScroll(scrollPosition);
|
setYScroll(scrollPosition);
|
||||||
};
|
};
|
||||||
|
|
||||||
const element = document.getElementById('admin-x-root');
|
const element = document.getElementById("admin-x-root");
|
||||||
if (settingsLoaded) {
|
const matchedRoute = handleNavigation();
|
||||||
const matchedRoute = handleNavigation();
|
setRoute(matchedRoute);
|
||||||
setRoute(matchedRoute);
|
element!.addEventListener("scroll", handleScroll);
|
||||||
element!.addEventListener('scroll', handleScroll);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('hashchange', handleHashChange);
|
window.addEventListener("hashchange", handleHashChange);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
element!.removeEventListener('scroll', handleScroll);
|
element!.removeEventListener("scroll", handleScroll);
|
||||||
window.removeEventListener('hashchange', handleHashChange);
|
window.removeEventListener("hashchange", handleHashChange);
|
||||||
};
|
};
|
||||||
}, [settingsLoaded]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RouteContext.Provider value={{
|
<RouteContext.Provider
|
||||||
route,
|
value={{
|
||||||
scrolledRoute,
|
route,
|
||||||
yScroll,
|
scrolledRoute,
|
||||||
updateRoute,
|
yScroll,
|
||||||
updateScrolled
|
updateRoute,
|
||||||
}}>
|
updateScrolled,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</RouteContext.Provider>
|
</RouteContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default RoutingProvider;
|
export default RoutingProvider;
|
||||||
|
@ -1,19 +1,11 @@
|
|||||||
import React, {createContext, useContext, useMemo} from 'react';
|
import React, {createContext, useContext} from 'react';
|
||||||
import setupGhostApi from '../../utils/api';
|
|
||||||
import useDataService, {DataService, bulkEdit, placeholderDataService} from '../../utils/dataService';
|
|
||||||
import useSearchService, {SearchService} from '../../utils/search';
|
import useSearchService, {SearchService} from '../../utils/search';
|
||||||
import {OfficialTheme} from '../../models/themes';
|
import {OfficialTheme} from '../../models/themes';
|
||||||
import {Tier} from '../../types/api';
|
|
||||||
|
|
||||||
export interface FileService {
|
|
||||||
uploadImage: (file: File) => Promise<string>;
|
|
||||||
}
|
|
||||||
interface ServicesContextProps {
|
interface ServicesContextProps {
|
||||||
api: ReturnType<typeof setupGhostApi>;
|
ghostVersion: string
|
||||||
fileService: FileService|null;
|
|
||||||
officialThemes: OfficialTheme[];
|
officialThemes: OfficialTheme[];
|
||||||
search: SearchService
|
search: SearchService
|
||||||
tiers: DataService<Tier>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServicesProviderProps {
|
interface ServicesProviderProps {
|
||||||
@ -23,36 +15,19 @@ interface ServicesProviderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ServicesContext = createContext<ServicesContextProps>({
|
const ServicesContext = createContext<ServicesContextProps>({
|
||||||
api: setupGhostApi({ghostVersion: ''}),
|
ghostVersion: '',
|
||||||
fileService: null,
|
|
||||||
officialThemes: [],
|
officialThemes: [],
|
||||||
search: {filter: '', setFilter: () => {}, checkVisible: () => true},
|
search: {filter: '', setFilter: () => {}, checkVisible: () => true}
|
||||||
tiers: placeholderDataService
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersion, officialThemes}) => {
|
const ServicesProvider: React.FC<ServicesProviderProps> = ({children, ghostVersion, officialThemes}) => {
|
||||||
const apiService = useMemo(() => setupGhostApi({ghostVersion}), [ghostVersion]);
|
|
||||||
const fileService = useMemo(() => ({
|
|
||||||
uploadImage: async (file: File): Promise<string> => {
|
|
||||||
const response = await apiService.images.upload({file});
|
|
||||||
return response.images[0].url;
|
|
||||||
}
|
|
||||||
}), [apiService]);
|
|
||||||
const search = useSearchService();
|
const search = useSearchService();
|
||||||
const tiers = useDataService({
|
|
||||||
key: 'tiers',
|
|
||||||
browse: apiService.tiers.browse,
|
|
||||||
edit: bulkEdit('tiers', apiService.tiers.edit),
|
|
||||||
add: apiService.tiers.add
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServicesContext.Provider value={{
|
<ServicesContext.Provider value={{
|
||||||
api: apiService,
|
ghostVersion,
|
||||||
fileService,
|
|
||||||
officialThemes,
|
officialThemes,
|
||||||
search,
|
search
|
||||||
tiers
|
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</ServicesContext.Provider>
|
</ServicesContext.Provider>
|
||||||
@ -63,10 +38,6 @@ export {ServicesContext, ServicesProvider};
|
|||||||
|
|
||||||
export const useServices = () => useContext(ServicesContext);
|
export const useServices = () => useContext(ServicesContext);
|
||||||
|
|
||||||
export const useApi = () => useServices().api;
|
|
||||||
|
|
||||||
export const useOfficialThemes = () => useServices().officialThemes;
|
export const useOfficialThemes = () => useServices().officialThemes;
|
||||||
|
|
||||||
export const useSearch = () => useServices().search;
|
export const useSearch = () => useServices().search;
|
||||||
|
|
||||||
export const useTiers = () => useServices().tiers;
|
|
||||||
|
@ -1,146 +0,0 @@
|
|||||||
import React, {createContext, useCallback, useContext, useEffect, useState} from 'react';
|
|
||||||
import {Config, Setting, SiteData} from '../../types/api';
|
|
||||||
import {ServicesContext} from './ServiceProvider';
|
|
||||||
import {SettingsResponseType} from '../../utils/api';
|
|
||||||
|
|
||||||
// Define the Settings Context
|
|
||||||
interface SettingsContextProps {
|
|
||||||
settings: Setting[] | null;
|
|
||||||
saveSettings: (updatedSettings: Setting[]) => Promise<SettingsResponseType>;
|
|
||||||
siteData: SiteData | null;
|
|
||||||
config: Config | null;
|
|
||||||
settingsLoaded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingsProviderProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SettingsContext = createContext<SettingsContextProps>({
|
|
||||||
settings: null,
|
|
||||||
siteData: null,
|
|
||||||
config: null,
|
|
||||||
settingsLoaded: false,
|
|
||||||
saveSettings: async () => ({settings: []})
|
|
||||||
});
|
|
||||||
|
|
||||||
function serialiseSettingsData(settings: Setting[]): Setting[] {
|
|
||||||
return settings.map((setting) => {
|
|
||||||
if (setting.key === 'facebook' && setting.value) {
|
|
||||||
const value = setting.value as string;
|
|
||||||
let [, user] = value.match(/(\S+)/) || [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: setting.key,
|
|
||||||
value: `https://www.facebook.com/${user}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (setting.key === 'twitter' && setting.value) {
|
|
||||||
const value = setting.value as string;
|
|
||||||
let [, user] = value.match(/@?([^/]*)/) || [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: setting.key,
|
|
||||||
value: `https://twitter.com/${user}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: setting.key,
|
|
||||||
value: setting.value
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function deserializeSettings(settings: Setting[]): Setting[] {
|
|
||||||
return settings.map((setting) => {
|
|
||||||
if (setting.key === 'facebook' && setting.value) {
|
|
||||||
const deserialized = setting.value as string;
|
|
||||||
let [, user] = deserialized.match(/(?:https:\/\/)(?:www\.)(?:facebook\.com)\/(?:#!\/)?(\w+\/?\S+)/mi) || [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: setting.key,
|
|
||||||
value: user
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (setting.key === 'twitter' && setting.value) {
|
|
||||||
const deserialized = setting.value as string;
|
|
||||||
let [, user] = deserialized.match(/(?:https:\/\/)(?:twitter\.com)\/(?:#!\/)?@?([^/]*)/) || [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: setting.key,
|
|
||||||
value: `@${user}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
key: setting.key,
|
|
||||||
value: setting.value
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a Settings Provider component
|
|
||||||
const SettingsProvider: React.FC<SettingsProviderProps> = ({children}) => {
|
|
||||||
const {api} = useContext(ServicesContext);
|
|
||||||
const [settings, setSettings] = useState<Setting[] | null> (null);
|
|
||||||
const [siteData, setSiteData] = useState<SiteData | null> (null);
|
|
||||||
const [config, setConfig] = useState<Config | null> (null);
|
|
||||||
const [settingsLoaded, setSettingsLoaded] = useState<boolean> (false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchSettings = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Make an API call to fetch the settings
|
|
||||||
const [settingsData, siteDataResponse, configData] = await Promise.all([
|
|
||||||
api.settings.browse(),
|
|
||||||
api.site.browse(),
|
|
||||||
api.config.browse()
|
|
||||||
]);
|
|
||||||
|
|
||||||
setSettings(serialiseSettingsData(settingsData.settings));
|
|
||||||
setSiteData(siteDataResponse.site);
|
|
||||||
setConfig(configData.config);
|
|
||||||
setSettingsLoaded(true);
|
|
||||||
} catch (error) {
|
|
||||||
// Log error in settings API
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch the initial settings from the API
|
|
||||||
fetchSettings();
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
const saveSettings = useCallback(async (updatedSettings: Setting[]) => {
|
|
||||||
try {
|
|
||||||
// handle transformation for settings before save
|
|
||||||
updatedSettings = deserializeSettings(updatedSettings);
|
|
||||||
// Make an API call to save the updated settings
|
|
||||||
const data = await api.settings.edit(updatedSettings);
|
|
||||||
const newSettings = serialiseSettingsData(data.settings);
|
|
||||||
|
|
||||||
setSettings(newSettings);
|
|
||||||
|
|
||||||
return {
|
|
||||||
settings: newSettings,
|
|
||||||
meta: data.meta
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
// Log error in settings API
|
|
||||||
return {settings: []};
|
|
||||||
}
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
// Provide the settings and the saveSettings function to the children components
|
|
||||||
return (
|
|
||||||
<SettingsContext.Provider value={{
|
|
||||||
settings, saveSettings, siteData, config, settingsLoaded
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</SettingsContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export {SettingsContext, SettingsProvider};
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
|||||||
import React, {createContext, useCallback, useContext, useEffect, useState} from 'react';
|
|
||||||
import {ServicesContext} from './ServiceProvider';
|
|
||||||
import {User} from '../../types/api';
|
|
||||||
import {UserInvite} from '../../utils/api';
|
|
||||||
|
|
||||||
interface UsersContextProps {
|
|
||||||
users: User[];
|
|
||||||
invites: UserInvite[];
|
|
||||||
currentUser: User|null;
|
|
||||||
updateUser?: (user: User) => Promise<void>;
|
|
||||||
setInvites: (invites: UserInvite[]) => void;
|
|
||||||
setUsers: React.Dispatch<React.SetStateAction<User[]>>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UsersProviderProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const UsersContext = createContext<UsersContextProps>({
|
|
||||||
users: [],
|
|
||||||
invites: [],
|
|
||||||
currentUser: null,
|
|
||||||
setInvites: () => {},
|
|
||||||
setUsers: () => {}
|
|
||||||
});
|
|
||||||
|
|
||||||
const UsersProvider: React.FC<UsersProviderProps> = ({children}) => {
|
|
||||||
const {api} = useContext(ServicesContext);
|
|
||||||
const [users, setUsers] = useState <User[]> ([]);
|
|
||||||
const [invites, setInvites] = useState <UserInvite[]> ([]);
|
|
||||||
const [currentUser, setCurrentUser] = useState <User|null> (null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchUsers = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// get list of staff users from the API
|
|
||||||
const data = await api.users.browse();
|
|
||||||
const user = await api.users.currentUser();
|
|
||||||
const invitesRes = await api.invites.browse();
|
|
||||||
setUsers(data.users);
|
|
||||||
setCurrentUser(user);
|
|
||||||
setInvites(invitesRes.invites);
|
|
||||||
} catch (error) {
|
|
||||||
// Log error in API
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchUsers();
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
const updateUser = useCallback(async (user: User): Promise<void> => {
|
|
||||||
try {
|
|
||||||
// Make an API call to save the updated settings
|
|
||||||
const data = await api.users.edit(user);
|
|
||||||
setUsers((usersState) => {
|
|
||||||
return usersState.map((u) => {
|
|
||||||
if (u.id === user.id) {
|
|
||||||
return data.users[0];
|
|
||||||
}
|
|
||||||
return u;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
// Log error in settings API
|
|
||||||
}
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UsersContext.Provider value={{
|
|
||||||
users,
|
|
||||||
invites,
|
|
||||||
currentUser,
|
|
||||||
updateUser,
|
|
||||||
setInvites,
|
|
||||||
setUsers
|
|
||||||
}}>
|
|
||||||
{children}
|
|
||||||
</UsersContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export {UsersContext, UsersProvider};
|
|
@ -1,13 +1,14 @@
|
|||||||
import MultiSelect, {MultiSelectOption} from '../../../admin-x-ds/global/form/MultiSelect';
|
import MultiSelect, {MultiSelectOption} from '../../../admin-x-ds/global/form/MultiSelect';
|
||||||
import React, {useContext, useEffect, useState} from 'react';
|
import React, {useState} from 'react';
|
||||||
import Select from '../../../admin-x-ds/global/form/Select';
|
import Select from '../../../admin-x-ds/global/form/Select';
|
||||||
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
||||||
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
|
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
|
||||||
import useSettingGroup from '../../../hooks/useSettingGroup';
|
import useSettingGroup from '../../../hooks/useSettingGroup';
|
||||||
import {GroupBase, MultiValue} from 'react-select';
|
import {GroupBase, MultiValue} from 'react-select';
|
||||||
import {Label, Offer, Tier} from '../../../types/api';
|
import {getOptionLabel, getSettingValues} from '../../../utils/helpers';
|
||||||
import {ServicesContext} from '../../providers/ServiceProvider';
|
import {useBrowseLabels} from '../../../utils/api/labels';
|
||||||
import {getOptionLabel, getPaidActiveTiers, getSettingValues} from '../../../utils/helpers';
|
import {useBrowseOffers} from '../../../utils/api/offers';
|
||||||
|
import {useGlobalData} from '../../providers/DataProvider';
|
||||||
|
|
||||||
type RefipientValueArgs = {
|
type RefipientValueArgs = {
|
||||||
defaultEmailRecipients: string;
|
defaultEmailRecipients: string;
|
||||||
@ -80,24 +81,9 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||||||
defaultEmailRecipientsFilter
|
defaultEmailRecipientsFilter
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const {api} = useContext(ServicesContext);
|
const {tiers} = useGlobalData();
|
||||||
const [tiers, setTiers] = useState<Tier[]>([]);
|
const {data: {labels} = {}} = useBrowseLabels();
|
||||||
const [labels, setLabels] = useState<Label[]>([]);
|
const {data: {offers} = {}} = useBrowseOffers();
|
||||||
const [offers, setOffers] = useState<Offer[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.tiers.browse().then((response) => {
|
|
||||||
setTiers(getPaidActiveTiers(response.tiers));
|
|
||||||
});
|
|
||||||
|
|
||||||
api.labels.browse().then((response) => {
|
|
||||||
setLabels(response.labels);
|
|
||||||
});
|
|
||||||
|
|
||||||
api.offers.browse().then((response) => {
|
|
||||||
setOffers(response.offers);
|
|
||||||
});
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
const setDefaultRecipientValue = (value: string) => {
|
const setDefaultRecipientValue = (value: string) => {
|
||||||
if (['visibility', 'disabled'].includes(value)) {
|
if (['visibility', 'disabled'].includes(value)) {
|
||||||
@ -136,11 +122,11 @@ const DefaultRecipients: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Labels',
|
label: 'Labels',
|
||||||
options: labels.map(label => ({value: `label:${label.slug}`, label: label.name, color: 'grey'}))
|
options: labels?.map(label => ({value: `label:${label.slug}`, label: label.name, color: 'grey'})) || []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Offers',
|
label: 'Offers',
|
||||||
options: offers.map(offer => ({value: `offer_redemptions:${offer.id}`, label: offer.name, color: 'black'}))
|
options: offers?.map(offer => ({value: `offer_redemptions:${offer.id}`, label: offer.name, color: 'black'})) || []
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import ImageUpload from '../../../admin-x-ds/global/form/ImageUpload';
|
import ImageUpload from '../../../admin-x-ds/global/form/ImageUpload';
|
||||||
import React, {useContext} from 'react';
|
import React from 'react';
|
||||||
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
||||||
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
|
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
|
||||||
import TextField from '../../../admin-x-ds/global/form/TextField';
|
import TextField from '../../../admin-x-ds/global/form/TextField';
|
||||||
import useSettingGroup from '../../../hooks/useSettingGroup';
|
import useSettingGroup from '../../../hooks/useSettingGroup';
|
||||||
import {ReactComponent as FacebookLogo} from '../../../admin-x-ds/assets/images/facebook-logo.svg';
|
import {ReactComponent as FacebookLogo} from '../../../admin-x-ds/assets/images/facebook-logo.svg';
|
||||||
import {FileService, ServicesContext} from '../../providers/ServiceProvider';
|
import {getImageUrl, useUploadImage} from '../../../utils/api/images';
|
||||||
import {getSettingValues} from '../../../utils/helpers';
|
import {getSettingValues} from '../../../utils/helpers';
|
||||||
|
|
||||||
const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||||
@ -20,7 +20,7 @@ const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||||||
handleEditingChange
|
handleEditingChange
|
||||||
} = useSettingGroup();
|
} = useSettingGroup();
|
||||||
|
|
||||||
const {fileService} = useContext(ServicesContext) as {fileService: FileService};
|
const {mutateAsync: uploadImage} = useUploadImage();
|
||||||
|
|
||||||
const [
|
const [
|
||||||
facebookTitle, facebookDescription, facebookImage, siteTitle, siteDescription
|
facebookTitle, facebookDescription, facebookImage, siteTitle, siteDescription
|
||||||
@ -35,7 +35,7 @@ const Facebook: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleImageUpload = async (file: File) => {
|
const handleImageUpload = async (file: File) => {
|
||||||
const imageUrl = await fileService.uploadImage(file);
|
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||||
updateSetting('og_image', imageUrl);
|
updateSetting('og_image', imageUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2,20 +2,19 @@ import Modal from '../../../admin-x-ds/global/modal/Modal';
|
|||||||
import NiceModal from '@ebay/nice-modal-react';
|
import NiceModal from '@ebay/nice-modal-react';
|
||||||
import Radio from '../../../admin-x-ds/global/form/Radio';
|
import Radio from '../../../admin-x-ds/global/form/Radio';
|
||||||
import TextField from '../../../admin-x-ds/global/form/TextField';
|
import TextField from '../../../admin-x-ds/global/form/TextField';
|
||||||
import useRoles from '../../../hooks/useRoles';
|
|
||||||
import useRouting from '../../../hooks/useRouting';
|
import useRouting from '../../../hooks/useRouting';
|
||||||
import useStaffUsers from '../../../hooks/useStaffUsers';
|
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
import {ServicesContext} from '../../providers/ServiceProvider';
|
import { showToast } from '../../../admin-x-ds/global/Toast';
|
||||||
import {showToast} from '../../../admin-x-ds/global/Toast';
|
import { useAddInvite } from '../../../utils/api/invites';
|
||||||
import {useContext, useEffect, useRef, useState} from 'react';
|
import { useBrowseRoles } from '../../../utils/api/roles';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
type RoleType = 'administrator' | 'editor' | 'author' | 'contributor';
|
type RoleType = 'administrator' | 'editor' | 'author' | 'contributor';
|
||||||
|
|
||||||
const InviteUserModal = NiceModal.create(() => {
|
const InviteUserModal = NiceModal.create(() => {
|
||||||
const {api} = useContext(ServicesContext);
|
const rolesQuery = useBrowseRoles();
|
||||||
const {roles, assignableRoles, getRoleId} = useRoles();
|
const assignableRolesQuery = useBrowseRoles({limit: 'all', permissions: 'assign'});
|
||||||
const {invites, setInvites} = useStaffUsers();
|
|
||||||
const {updateRoute} = useRouting();
|
const {updateRoute} = useRouting();
|
||||||
|
|
||||||
const focusRef = useRef<HTMLInputElement>(null);
|
const focusRef = useRef<HTMLInputElement>(null);
|
||||||
@ -26,6 +25,8 @@ const InviteUserModal = NiceModal.create(() => {
|
|||||||
email?: string;
|
email?: string;
|
||||||
}>({});
|
}>({});
|
||||||
|
|
||||||
|
const {mutateAsync: addInvite} = useAddInvite();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusRef.current) {
|
if (focusRef.current) {
|
||||||
focusRef.current.focus();
|
focusRef.current.focus();
|
||||||
@ -40,6 +41,13 @@ const InviteUserModal = NiceModal.create(() => {
|
|||||||
}
|
}
|
||||||
}, [saveState]);
|
}, [saveState]);
|
||||||
|
|
||||||
|
if (!rolesQuery.data?.roles || !assignableRolesQuery.data?.roles) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roles = rolesQuery.data.roles;
|
||||||
|
const assignableRoles = assignableRolesQuery.data.roles;
|
||||||
|
|
||||||
let okLabel = 'Send invitation now';
|
let okLabel = 'Send invitation now';
|
||||||
if (saveState === 'saving') {
|
if (saveState === 'saving') {
|
||||||
okLabel = 'Sending...';
|
okLabel = 'Sending...';
|
||||||
@ -62,21 +70,18 @@ const InviteUserModal = NiceModal.create(() => {
|
|||||||
}
|
}
|
||||||
setSaveState('saving');
|
setSaveState('saving');
|
||||||
try {
|
try {
|
||||||
const res = await api.invites.add({
|
await addInvite({
|
||||||
email,
|
email,
|
||||||
roleId: getRoleId(role, roles)
|
roleId: roles.find(({name}) => name.toLowerCase() === role.toLowerCase())!.id
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update invites list
|
|
||||||
setInvites([...invites, res.invites[0]]);
|
|
||||||
|
|
||||||
setSaveState('saved');
|
setSaveState('saved');
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
message: `Invitation successfully sent to ${email}`,
|
message: `Invitation successfully sent to ${email}`,
|
||||||
type: 'success'
|
type: 'success'
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
setSaveState('error');
|
setSaveState('error');
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import React, {useRef, useState} from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
||||||
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
|
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
|
||||||
import TextField from '../../../admin-x-ds/global/form/TextField';
|
import TextField from '../../../admin-x-ds/global/form/TextField';
|
||||||
import useSettingGroup from '../../../hooks/useSettingGroup';
|
import useSettingGroup from '../../../hooks/useSettingGroup';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
import {getSettingValues} from '../../../utils/helpers';
|
import { getSettingValues } from '../../../utils/helpers';
|
||||||
|
|
||||||
function validateFacebookUrl(newUrl: string) {
|
function validateFacebookUrl(newUrl: string) {
|
||||||
const errMessage = 'The URL must be in a format like https://www.facebook.com/yourPage';
|
const errMessage = 'The URL must be in a format like https://www.facebook.com/yourPage';
|
||||||
@ -121,7 +121,7 @@ const SocialAccounts: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||||||
if (focusRef.current) {
|
if (focusRef.current) {
|
||||||
focusRef.current.value = newUrl;
|
focusRef.current.value = newUrl;
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
// ignore error
|
// ignore error
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -143,7 +143,7 @@ const SocialAccounts: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||||||
if (twitterInputRef.current) {
|
if (twitterInputRef.current) {
|
||||||
twitterInputRef.current.value = newUrl;
|
twitterInputRef.current.value = newUrl;
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
// ignore error
|
// ignore error
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -172,14 +172,18 @@ const SocialAccounts: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||||||
} = {};
|
} = {};
|
||||||
try {
|
try {
|
||||||
validateFacebookUrl(facebookUrl);
|
validateFacebookUrl(facebookUrl);
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
formErrors.facebook = e?.message;
|
if (e instanceof Error) {
|
||||||
|
formErrors.facebook = e.message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
validateTwitterUrl(twitterUrl);
|
validateTwitterUrl(twitterUrl);
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
formErrors.twitter = e?.message;
|
if (e instanceof Error) {
|
||||||
|
formErrors.twitter = e.message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors(formErrors);
|
setErrors(formErrors);
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import ImageUpload from '../../../admin-x-ds/global/form/ImageUpload';
|
import ImageUpload from '../../../admin-x-ds/global/form/ImageUpload';
|
||||||
import React, {useContext} from 'react';
|
import React from 'react';
|
||||||
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
||||||
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
|
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
|
||||||
import TextField from '../../../admin-x-ds/global/form/TextField';
|
import TextField from '../../../admin-x-ds/global/form/TextField';
|
||||||
import useSettingGroup from '../../../hooks/useSettingGroup';
|
import useSettingGroup from '../../../hooks/useSettingGroup';
|
||||||
import {FileService, ServicesContext} from '../../providers/ServiceProvider';
|
import { ReactComponent as TwitterLogo } from '../../../admin-x-ds/assets/images/twitter-logo.svg';
|
||||||
import {ReactComponent as TwitterLogo} from '../../../admin-x-ds/assets/images/twitter-logo.svg';
|
import { getImageUrl, useUploadImage } from '../../../utils/api/images';
|
||||||
import {getSettingValues} from '../../../utils/helpers';
|
import { getSettingValues } from '../../../utils/helpers';
|
||||||
|
|
||||||
const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||||
const {
|
const {
|
||||||
@ -20,7 +20,7 @@ const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||||||
handleEditingChange
|
handleEditingChange
|
||||||
} = useSettingGroup();
|
} = useSettingGroup();
|
||||||
|
|
||||||
const {fileService} = useContext(ServicesContext) as {fileService: FileService};
|
const {mutateAsync: uploadImage} = useUploadImage();
|
||||||
|
|
||||||
const [
|
const [
|
||||||
twitterTitle, twitterDescription, twitterImage, siteTitle, siteDescription
|
twitterTitle, twitterDescription, twitterImage, siteTitle, siteDescription
|
||||||
@ -36,10 +36,10 @@ const Twitter: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||||||
|
|
||||||
const handleImageUpload = async (file: File) => {
|
const handleImageUpload = async (file: File) => {
|
||||||
try {
|
try {
|
||||||
const imageUrl = await fileService.uploadImage(file);
|
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||||
updateSetting('twitter_image', imageUrl);
|
updateSetting('twitter_image', imageUrl);
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
// handle error
|
// TODO: handle error
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -3,23 +3,24 @@ import ConfirmationModal from '../../../admin-x-ds/global/modal/ConfirmationModa
|
|||||||
import Heading from '../../../admin-x-ds/global/Heading';
|
import Heading from '../../../admin-x-ds/global/Heading';
|
||||||
import Icon from '../../../admin-x-ds/global/Icon';
|
import Icon from '../../../admin-x-ds/global/Icon';
|
||||||
import ImageUpload from '../../../admin-x-ds/global/form/ImageUpload';
|
import ImageUpload from '../../../admin-x-ds/global/form/ImageUpload';
|
||||||
import Menu, {MenuItem} from '../../../admin-x-ds/global/Menu';
|
import Menu, { MenuItem } from '../../../admin-x-ds/global/Menu';
|
||||||
import Modal from '../../../admin-x-ds/global/modal/Modal';
|
import Modal from '../../../admin-x-ds/global/modal/Modal';
|
||||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
import NiceModal, { useModal } from '@ebay/nice-modal-react';
|
||||||
import Radio from '../../../admin-x-ds/global/form/Radio';
|
import Radio from '../../../admin-x-ds/global/form/Radio';
|
||||||
import React, {useContext, useEffect, useRef, useState} from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
||||||
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
|
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
|
||||||
import TextField from '../../../admin-x-ds/global/form/TextField';
|
import TextField from '../../../admin-x-ds/global/form/TextField';
|
||||||
import Toggle from '../../../admin-x-ds/global/form/Toggle';
|
import Toggle from '../../../admin-x-ds/global/form/Toggle';
|
||||||
import useRoles from '../../../hooks/useRoles';
|
|
||||||
import useStaffUsers from '../../../hooks/useStaffUsers';
|
import useStaffUsers from '../../../hooks/useStaffUsers';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
import {FileService, ServicesContext} from '../../providers/ServiceProvider';
|
import { User } from '../../../types/api';
|
||||||
import {User} from '../../../types/api';
|
import { getImageUrl, useUploadImage } from '../../../utils/api/images';
|
||||||
import {isAdminUser, isOwnerUser} from '../../../utils/helpers';
|
import { isAdminUser, isOwnerUser } from '../../../utils/helpers';
|
||||||
import {showToast} from '../../../admin-x-ds/global/Toast';
|
import { showToast } from '../../../admin-x-ds/global/Toast';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
|
import { useBrowseRoles } from '../../../utils/api/roles';
|
||||||
|
import { useDeleteUser, useEditUser, useMakeOwner, useUpdatePassword } from '../../../utils/api/users';
|
||||||
|
|
||||||
interface CustomHeadingProps {
|
interface CustomHeadingProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@ -47,7 +48,8 @@ const CustomHeader: React.FC<CustomHeadingProps> = ({children}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RoleSelector: React.FC<UserDetailProps> = ({user, setUserData}) => {
|
const RoleSelector: React.FC<UserDetailProps> = ({user, setUserData}) => {
|
||||||
const {roles} = useRoles();
|
const {data: {roles} = {}} = useBrowseRoles();
|
||||||
|
|
||||||
if (isOwnerUser(user)) {
|
if (isOwnerUser(user)) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -303,7 +305,8 @@ const Password: React.FC<UserDetailProps> = ({user}) => {
|
|||||||
}>({});
|
}>({});
|
||||||
const newPasswordRef = useRef<HTMLInputElement>(null);
|
const newPasswordRef = useRef<HTMLInputElement>(null);
|
||||||
const confirmNewPasswordRef = useRef<HTMLInputElement>(null);
|
const confirmNewPasswordRef = useRef<HTMLInputElement>(null);
|
||||||
const {api} = useContext(ServicesContext);
|
|
||||||
|
const {mutateAsync: updatePassword} = useUpdatePassword();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (saveState === 'saved') {
|
if (saveState === 'saved') {
|
||||||
@ -378,7 +381,7 @@ const Password: React.FC<UserDetailProps> = ({user}) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.users.updatePassword({
|
await updatePassword({
|
||||||
newPassword,
|
newPassword,
|
||||||
confirmNewPassword,
|
confirmNewPassword,
|
||||||
oldPassword: '',
|
oldPassword: '',
|
||||||
@ -408,7 +411,6 @@ const Password: React.FC<UserDetailProps> = ({user}) => {
|
|||||||
|
|
||||||
interface UserDetailModalProps {
|
interface UserDetailModalProps {
|
||||||
user: User;
|
user: User;
|
||||||
updateUser?: (user: User) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserMenuTrigger = () => (
|
const UserMenuTrigger = () => (
|
||||||
@ -418,9 +420,8 @@ const UserMenuTrigger = () => (
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
|
const UserDetailModal:React.FC<UserDetailModalProps> = ({user}) => {
|
||||||
const {api} = useContext(ServicesContext);
|
const {ownerUser} = useStaffUsers();
|
||||||
const {users, setUsers, ownerUser} = useStaffUsers();
|
|
||||||
const [userData, setUserData] = useState(user);
|
const [userData, setUserData] = useState(user);
|
||||||
const [saveState, setSaveState] = useState('');
|
const [saveState, setSaveState] = useState('');
|
||||||
const [errors, setErrors] = useState<{
|
const [errors, setErrors] = useState<{
|
||||||
@ -429,8 +430,11 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
|
|||||||
url?: string;
|
url?: string;
|
||||||
}>({});
|
}>({});
|
||||||
|
|
||||||
const {fileService} = useContext(ServicesContext) as {fileService: FileService};
|
|
||||||
const mainModal = useModal();
|
const mainModal = useModal();
|
||||||
|
const {mutateAsync: uploadImage} = useUploadImage();
|
||||||
|
const {mutateAsync: updateUser} = useEditUser();
|
||||||
|
const {mutateAsync: deleteUser} = useDeleteUser();
|
||||||
|
const {mutateAsync: makeOwner} = useMakeOwner();
|
||||||
|
|
||||||
const confirmSuspend = (_user: User) => {
|
const confirmSuspend = (_user: User) => {
|
||||||
let warningText = 'This user will no longer be able to log in but their posts will be kept.';
|
let warningText = 'This user will no longer be able to log in but their posts will be kept.';
|
||||||
@ -452,16 +456,7 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
|
|||||||
..._user,
|
..._user,
|
||||||
status: _user.status === 'inactive' ? 'active' : 'inactive'
|
status: _user.status === 'inactive' ? 'active' : 'inactive'
|
||||||
};
|
};
|
||||||
const res = await api.users.edit(updatedUserData);
|
await updateUser(updatedUserData);
|
||||||
const updatedUser = res.users[0];
|
|
||||||
setUsers((_users) => {
|
|
||||||
return _users.map((u) => {
|
|
||||||
if (u.id === updatedUser.id) {
|
|
||||||
return updatedUser;
|
|
||||||
}
|
|
||||||
return u;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
setUserData(updatedUserData);
|
setUserData(updatedUserData);
|
||||||
modal?.remove();
|
modal?.remove();
|
||||||
showToast({
|
showToast({
|
||||||
@ -484,9 +479,7 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
|
|||||||
okLabel: 'Delete user',
|
okLabel: 'Delete user',
|
||||||
okColor: 'red',
|
okColor: 'red',
|
||||||
onOk: async (modal) => {
|
onOk: async (modal) => {
|
||||||
await api.users.delete(_user?.id);
|
await deleteUser(_user?.id);
|
||||||
const newUsers = users.filter(u => u.id !== _user.id);
|
|
||||||
setUsers(newUsers);
|
|
||||||
modal?.remove();
|
modal?.remove();
|
||||||
mainModal?.remove();
|
mainModal?.remove();
|
||||||
showToast({
|
showToast({
|
||||||
@ -504,8 +497,7 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
|
|||||||
okLabel: 'Yep — I\'m sure',
|
okLabel: 'Yep — I\'m sure',
|
||||||
okColor: 'red',
|
okColor: 'red',
|
||||||
onOk: async (modal) => {
|
onOk: async (modal) => {
|
||||||
const res = await api.users.makeOwner(user.id);
|
await makeOwner(user.id);
|
||||||
setUsers(res.users);
|
|
||||||
modal?.remove();
|
modal?.remove();
|
||||||
showToast({
|
showToast({
|
||||||
message: 'Ownership transferred',
|
message: 'Ownership transferred',
|
||||||
@ -517,7 +509,7 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
|
|||||||
|
|
||||||
const handleImageUpload = async (image: string, file: File) => {
|
const handleImageUpload = async (image: string, file: File) => {
|
||||||
try {
|
try {
|
||||||
const imageUrl = await fileService.uploadImage(file);
|
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||||
|
|
||||||
switch (image) {
|
switch (image) {
|
||||||
case 'cover_image':
|
case 'cover_image':
|
||||||
@ -531,8 +523,8 @@ const UserDetailModal:React.FC<UserDetailModalProps> = ({user, updateUser}) => {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
// handle error
|
// TODO: handle error
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -4,15 +4,14 @@ import List from '../../../admin-x-ds/global/List';
|
|||||||
import ListItem from '../../../admin-x-ds/global/ListItem';
|
import ListItem from '../../../admin-x-ds/global/ListItem';
|
||||||
import NiceModal from '@ebay/nice-modal-react';
|
import NiceModal from '@ebay/nice-modal-react';
|
||||||
import NoValueLabel from '../../../admin-x-ds/global/NoValueLabel';
|
import NoValueLabel from '../../../admin-x-ds/global/NoValueLabel';
|
||||||
import React, {useContext, useState} from 'react';
|
import React, {useState} from 'react';
|
||||||
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
||||||
import TabView from '../../../admin-x-ds/global/TabView';
|
import TabView from '../../../admin-x-ds/global/TabView';
|
||||||
import UserDetailModal from './UserDetailModal';
|
import UserDetailModal from './UserDetailModal';
|
||||||
import useRouting from '../../../hooks/useRouting';
|
import useRouting from '../../../hooks/useRouting';
|
||||||
import useStaffUsers from '../../../hooks/useStaffUsers';
|
import useStaffUsers from '../../../hooks/useStaffUsers';
|
||||||
import {ServicesContext} from '../../providers/ServiceProvider';
|
|
||||||
import {User} from '../../../types/api';
|
import {User} from '../../../types/api';
|
||||||
import {UserInvite} from '../../../utils/api';
|
import {UserInvite, useAddInvite, useDeleteInvite} from '../../../utils/api/invites';
|
||||||
import {generateAvatarColor, getInitials} from '../../../utils/helpers';
|
import {generateAvatarColor, getInitials} from '../../../utils/helpers';
|
||||||
import {showToast} from '../../../admin-x-ds/global/Toast';
|
import {showToast} from '../../../admin-x-ds/global/Toast';
|
||||||
|
|
||||||
@ -31,9 +30,9 @@ interface InviteListProps {
|
|||||||
updateUser?: (user: User) => void;
|
updateUser?: (user: User) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Owner: React.FC<OwnerProps> = ({user, updateUser}) => {
|
const Owner: React.FC<OwnerProps> = ({user}) => {
|
||||||
const showDetailModal = () => {
|
const showDetailModal = () => {
|
||||||
NiceModal.show(UserDetailModal, {user, updateUser});
|
NiceModal.show(UserDetailModal, {user});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@ -51,9 +50,9 @@ const Owner: React.FC<OwnerProps> = ({user, updateUser}) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const UsersList: React.FC<UsersListProps> = ({users, updateUser}) => {
|
const UsersList: React.FC<UsersListProps> = ({users}) => {
|
||||||
const showDetailModal = (user: User) => {
|
const showDetailModal = (user: User) => {
|
||||||
NiceModal.show(UserDetailModal, {user, updateUser});
|
NiceModal.show(UserDetailModal, {user});
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!users || !users.length) {
|
if (!users || !users.length) {
|
||||||
@ -91,10 +90,12 @@ const UsersList: React.FC<UsersListProps> = ({users, updateUser}) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
|
const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
|
||||||
const {api} = useContext(ServicesContext);
|
|
||||||
const {setInvites} = useStaffUsers();
|
|
||||||
const [revokeState, setRevokeState] = useState<'progress'|''>('');
|
const [revokeState, setRevokeState] = useState<'progress'|''>('');
|
||||||
const [resendState, setResendState] = useState<'progress'|''>('');
|
const [resendState, setResendState] = useState<'progress'|''>('');
|
||||||
|
|
||||||
|
const {mutateAsync: deleteInvite} = useDeleteInvite();
|
||||||
|
const {mutateAsync: addInvite} = useAddInvite();
|
||||||
|
|
||||||
let revokeActionLabel = 'Revoke';
|
let revokeActionLabel = 'Revoke';
|
||||||
if (revokeState === 'progress') {
|
if (revokeState === 'progress') {
|
||||||
revokeActionLabel = 'Revoking...';
|
revokeActionLabel = 'Revoking...';
|
||||||
@ -111,9 +112,7 @@ const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
|
|||||||
link={true}
|
link={true}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setRevokeState('progress');
|
setRevokeState('progress');
|
||||||
await api.invites.delete(invite.id);
|
await deleteInvite(invite.id);
|
||||||
const res = await api.invites.browse();
|
|
||||||
setInvites(res.invites);
|
|
||||||
setRevokeState('');
|
setRevokeState('');
|
||||||
showToast({
|
showToast({
|
||||||
message: `Invitation revoked (${invite.email})`,
|
message: `Invitation revoked (${invite.email})`,
|
||||||
@ -128,13 +127,11 @@ const UserInviteActions: React.FC<{invite: UserInvite}> = ({invite}) => {
|
|||||||
link={true}
|
link={true}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setResendState('progress');
|
setResendState('progress');
|
||||||
await api.invites.delete(invite.id);
|
await deleteInvite(invite.id);
|
||||||
await api.invites.add({
|
await addInvite({
|
||||||
email: invite.email,
|
email: invite.email,
|
||||||
roleId: invite.role_id
|
roleId: invite.role_id
|
||||||
});
|
});
|
||||||
const res = await api.invites.browse();
|
|
||||||
setInvites(res.invites);
|
|
||||||
setResendState('');
|
setResendState('');
|
||||||
showToast({
|
showToast({
|
||||||
message: `Invitation resent! (${invite.email})`,
|
message: `Invitation resent! (${invite.email})`,
|
||||||
@ -187,8 +184,7 @@ const Users: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||||||
editorUsers,
|
editorUsers,
|
||||||
authorUsers,
|
authorUsers,
|
||||||
contributorUsers,
|
contributorUsers,
|
||||||
invites,
|
invites
|
||||||
updateUser
|
|
||||||
} = useStaffUsers();
|
} = useStaffUsers();
|
||||||
const {updateRoute} = useRouting();
|
const {updateRoute} = useRouting();
|
||||||
const showInviteModal = () => {
|
const showInviteModal = () => {
|
||||||
@ -207,27 +203,27 @@ const Users: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||||||
{
|
{
|
||||||
id: 'users-admins',
|
id: 'users-admins',
|
||||||
title: 'Administrators',
|
title: 'Administrators',
|
||||||
contents: (<UsersList updateUser={updateUser} users={adminUsers} />)
|
contents: (<UsersList users={adminUsers} />)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'users-editors',
|
id: 'users-editors',
|
||||||
title: 'Editors',
|
title: 'Editors',
|
||||||
contents: (<UsersList updateUser={updateUser} users={editorUsers} />)
|
contents: (<UsersList users={editorUsers} />)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'users-authors',
|
id: 'users-authors',
|
||||||
title: 'Authors',
|
title: 'Authors',
|
||||||
contents: (<UsersList updateUser={updateUser} users={authorUsers} />)
|
contents: (<UsersList users={authorUsers} />)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'users-contributors',
|
id: 'users-contributors',
|
||||||
title: 'Contributors',
|
title: 'Contributors',
|
||||||
contents: (<UsersList updateUser={updateUser} users={contributorUsers} />)
|
contents: (<UsersList users={contributorUsers} />)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'users-invited',
|
id: 'users-invited',
|
||||||
title: 'Invited',
|
title: 'Invited',
|
||||||
contents: (<InvitesUserList updateUser={updateUser} users={invites} />)
|
contents: (<InvitesUserList users={invites} />)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -239,7 +235,7 @@ const Users: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||||||
testId='users'
|
testId='users'
|
||||||
title='Users and permissions'
|
title='Users and permissions'
|
||||||
>
|
>
|
||||||
<Owner updateUser={updateUser} user={ownerUser} />
|
<Owner user={ownerUser} />
|
||||||
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
|
<TabView selectedTab={selectedTab} tabs={tabs} onTabChange={setSelectedTab} />
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
);
|
);
|
||||||
|
@ -1,13 +1,12 @@
|
|||||||
import MultiSelect, {MultiSelectOption} from '../../../admin-x-ds/global/form/MultiSelect';
|
import MultiSelect, {MultiSelectOption} from '../../../admin-x-ds/global/form/MultiSelect';
|
||||||
import React, {useContext, useEffect, useState} from 'react';
|
import React from 'react';
|
||||||
import Select from '../../../admin-x-ds/global/form/Select';
|
import Select from '../../../admin-x-ds/global/form/Select';
|
||||||
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
||||||
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
|
import SettingGroupContent from '../../../admin-x-ds/settings/SettingGroupContent';
|
||||||
import useSettingGroup from '../../../hooks/useSettingGroup';
|
import useSettingGroup from '../../../hooks/useSettingGroup';
|
||||||
import {GroupBase, MultiValue} from 'react-select';
|
import {GroupBase, MultiValue} from 'react-select';
|
||||||
import {ServicesContext} from '../../providers/ServiceProvider';
|
import {getOptionLabel, getSettingValues} from '../../../utils/helpers';
|
||||||
import {Tier} from '../../../types/api';
|
import {useGlobalData} from '../../providers/DataProvider';
|
||||||
import {getOptionLabel, getPaidActiveTiers, getSettingValues} from '../../../utils/helpers';
|
|
||||||
|
|
||||||
const MEMBERS_SIGNUP_ACCESS_OPTIONS = [
|
const MEMBERS_SIGNUP_ACCESS_OPTIONS = [
|
||||||
{value: 'all', label: 'Anyone can sign up'},
|
{value: 'all', label: 'Anyone can sign up'},
|
||||||
@ -47,14 +46,7 @@ const Access: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||||||
const defaultContentVisibilityLabel = getOptionLabel(DEFAULT_CONTENT_VISIBILITY_OPTIONS, defaultContentVisibility);
|
const defaultContentVisibilityLabel = getOptionLabel(DEFAULT_CONTENT_VISIBILITY_OPTIONS, defaultContentVisibility);
|
||||||
const commentsEnabledLabel = getOptionLabel(COMMENTS_ENABLED_OPTIONS, commentsEnabled);
|
const commentsEnabledLabel = getOptionLabel(COMMENTS_ENABLED_OPTIONS, commentsEnabled);
|
||||||
|
|
||||||
const {api} = useContext(ServicesContext);
|
const {tiers} = useGlobalData();
|
||||||
const [tiers, setTiers] = useState<Tier[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.tiers.browse().then((response) => {
|
|
||||||
setTiers(getPaidActiveTiers(response.tiers));
|
|
||||||
});
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
const tierOptionGroups: GroupBase<MultiSelectOption>[] = [
|
const tierOptionGroups: GroupBase<MultiSelectOption>[] = [
|
||||||
{
|
{
|
||||||
|
@ -1,16 +1,16 @@
|
|||||||
import React, {useState} from 'react';
|
import React, { useState } from 'react';
|
||||||
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
import SettingGroup from '../../../admin-x-ds/settings/SettingGroup';
|
||||||
import StripeButton from '../../../admin-x-ds/settings/StripeButton';
|
import StripeButton from '../../../admin-x-ds/settings/StripeButton';
|
||||||
import TabView from '../../../admin-x-ds/global/TabView';
|
import TabView from '../../../admin-x-ds/global/TabView';
|
||||||
import TiersList from './tiers/TiersList';
|
import TiersList from './tiers/TiersList';
|
||||||
import useRouting from '../../../hooks/useRouting';
|
import useRouting from '../../../hooks/useRouting';
|
||||||
import {Tier} from '../../../types/api';
|
import { Tier } from '../../../types/api';
|
||||||
import {getActiveTiers, getArchivedTiers} from '../../../utils/helpers';
|
import { getActiveTiers, getArchivedTiers } from '../../../utils/helpers';
|
||||||
import {useTiers} from '../../providers/ServiceProvider';
|
import { useGlobalData } from '../../providers/DataProvider';
|
||||||
|
|
||||||
const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
||||||
const [selectedTab, setSelectedTab] = useState('active-tiers');
|
const [selectedTab, setSelectedTab] = useState('active-tiers');
|
||||||
const {data: tiers, update: updateTier} = useTiers();
|
const {tiers} = useGlobalData();
|
||||||
const activeTiers = getActiveTiers(tiers);
|
const activeTiers = getActiveTiers(tiers);
|
||||||
const archivedTiers = getArchivedTiers(tiers);
|
const archivedTiers = getArchivedTiers(tiers);
|
||||||
const {updateRoute} = useRouting();
|
const {updateRoute} = useRouting();
|
||||||
@ -34,12 +34,12 @@ const Tiers: React.FC<{ keywords: string[] }> = ({keywords}) => {
|
|||||||
{
|
{
|
||||||
id: 'active-tiers',
|
id: 'active-tiers',
|
||||||
title: 'Active',
|
title: 'Active',
|
||||||
contents: (<TiersList tab='active-tiers' tiers={sortTiers(activeTiers)} updateTier={updateTier} />)
|
contents: (<TiersList tab='active-tiers' tiers={sortTiers(activeTiers)} />)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'archived-tiers',
|
id: 'archived-tiers',
|
||||||
title: 'Archived',
|
title: 'Archived',
|
||||||
contents: (<TiersList tab='archive-tiers' tiers={sortTiers(archivedTiers)} updateTier={updateTier} />)
|
contents: (<TiersList tab='archive-tiers' tiers={sortTiers(archivedTiers)} />)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import Form from '../../../../admin-x-ds/global/form/Form';
|
import Form from '../../../../admin-x-ds/global/form/Form';
|
||||||
import React, {FocusEventHandler, useContext, useState} from 'react';
|
import React, {FocusEventHandler, useState} from 'react';
|
||||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||||
import {Setting, SettingValue} from '../../../../types/api';
|
import {Setting, SettingValue} from '../../../../types/api';
|
||||||
import {SettingsContext} from '../../../providers/SettingsProvider';
|
|
||||||
import {fullEmailAddress, getEmailDomain, getSettingValues} from '../../../../utils/helpers';
|
import {fullEmailAddress, getEmailDomain, getSettingValues} from '../../../../utils/helpers';
|
||||||
|
import {useGlobalData} from '../../../providers/DataProvider';
|
||||||
|
|
||||||
const AccountPage: React.FC<{
|
const AccountPage: React.FC<{
|
||||||
localSettings: Setting[]
|
localSettings: Setting[]
|
||||||
@ -11,7 +11,7 @@ const AccountPage: React.FC<{
|
|||||||
}> = ({localSettings, updateSetting}) => {
|
}> = ({localSettings, updateSetting}) => {
|
||||||
const [membersSupportAddress] = getSettingValues(localSettings, ['members_support_address']);
|
const [membersSupportAddress] = getSettingValues(localSettings, ['members_support_address']);
|
||||||
|
|
||||||
const {siteData} = useContext(SettingsContext) || {};
|
const {siteData} = useGlobalData();
|
||||||
const emailDomain = getEmailDomain(siteData!);
|
const emailDomain = getEmailDomain(siteData!);
|
||||||
|
|
||||||
const [value, setValue] = useState(fullEmailAddress(membersSupportAddress?.toString() || '', siteData!));
|
const [value, setValue] = useState(fullEmailAddress(membersSupportAddress?.toString() || '', siteData!));
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import Form from '../../../../admin-x-ds/global/form/Form';
|
import Form from '../../../../admin-x-ds/global/form/Form';
|
||||||
import React, {useContext, useState} from 'react';
|
import React, {useState} from 'react';
|
||||||
import Select from '../../../../admin-x-ds/global/form/Select';
|
import Select from '../../../../admin-x-ds/global/form/Select';
|
||||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||||
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
||||||
@ -15,7 +15,7 @@ import {ReactComponent as PortalIcon2} from '../../../../assets/icons/portal-ico
|
|||||||
import {ReactComponent as PortalIcon3} from '../../../../assets/icons/portal-icon-3.svg';
|
import {ReactComponent as PortalIcon3} from '../../../../assets/icons/portal-icon-3.svg';
|
||||||
import {ReactComponent as PortalIcon4} from '../../../../assets/icons/portal-icon-4.svg';
|
import {ReactComponent as PortalIcon4} from '../../../../assets/icons/portal-icon-4.svg';
|
||||||
import {ReactComponent as PortalIcon5} from '../../../../assets/icons/portal-icon-5.svg';
|
import {ReactComponent as PortalIcon5} from '../../../../assets/icons/portal-icon-5.svg';
|
||||||
import {ServicesContext} from '../../../providers/ServiceProvider';
|
import {getImageUrl, useUploadImage} from '../../../../utils/api/images';
|
||||||
|
|
||||||
const defaultButtonIcons = [
|
const defaultButtonIcons = [
|
||||||
{
|
{
|
||||||
@ -44,7 +44,7 @@ const LookAndFeel: React.FC<{
|
|||||||
localSettings: Setting[]
|
localSettings: Setting[]
|
||||||
updateSetting: (key: string, setting: SettingValue) => void
|
updateSetting: (key: string, setting: SettingValue) => void
|
||||||
}> = ({localSettings, updateSetting}) => {
|
}> = ({localSettings, updateSetting}) => {
|
||||||
const {fileService} = useContext(ServicesContext);
|
const {mutateAsync: uploadImage} = useUploadImage();
|
||||||
|
|
||||||
const [portalButton, portalButtonStyle, portalButtonIcon, portalButtonSignupText] = getSettingValues(localSettings, ['portal_button', 'portal_button_style', 'portal_button_icon', 'portal_button_signup_text']);
|
const [portalButton, portalButtonStyle, portalButtonIcon, portalButtonSignupText] = getSettingValues(localSettings, ['portal_button', 'portal_button_style', 'portal_button_icon', 'portal_button_signup_text']);
|
||||||
|
|
||||||
@ -54,7 +54,7 @@ const LookAndFeel: React.FC<{
|
|||||||
const [uploadedIcon, setUploadedIcon] = useState(isDefaultIcon ? undefined : currentIcon);
|
const [uploadedIcon, setUploadedIcon] = useState(isDefaultIcon ? undefined : currentIcon);
|
||||||
|
|
||||||
const handleImageUpload = async (file: File) => {
|
const handleImageUpload = async (file: File) => {
|
||||||
const imageUrl = await fileService!.uploadImage(file);
|
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||||
updateSetting('portal_button_icon', imageUrl);
|
updateSetting('portal_button_icon', imageUrl);
|
||||||
setUploadedIcon(imageUrl);
|
setUploadedIcon(imageUrl);
|
||||||
};
|
};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import React, {useEffect, useRef, useState} from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import useSettingGroup from '../../../../hooks/useSettingGroup';
|
import useSettingGroup from '../../../../hooks/useSettingGroup';
|
||||||
import {Setting, SiteData, Tier} from '../../../../types/api';
|
import { Setting, SiteData, Tier } from '../../../../types/api';
|
||||||
import {getSettingValue} from '../../../../utils/helpers';
|
import { getSettingValue } from '../../../../utils/helpers';
|
||||||
|
|
||||||
type PortalFrameProps = {
|
type PortalFrameProps = {
|
||||||
settings: Setting[];
|
settings: Setting[];
|
||||||
@ -91,7 +91,7 @@ const PortalFrame: React.FC<PortalFrameProps> = ({settings, tiers, selectedTab})
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const messageListener = (event: any) => {
|
const messageListener = (event: MessageEvent<'portal-ready' | {type: string}>) => {
|
||||||
if (!href) {
|
if (!href) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -2,12 +2,11 @@ import Button from '../../../../admin-x-ds/global/Button';
|
|||||||
import List from '../../../../admin-x-ds/global/List';
|
import List from '../../../../admin-x-ds/global/List';
|
||||||
import ListItem from '../../../../admin-x-ds/global/ListItem';
|
import ListItem from '../../../../admin-x-ds/global/ListItem';
|
||||||
import ModalPage from '../../../../admin-x-ds/global/modal/ModalPage';
|
import ModalPage from '../../../../admin-x-ds/global/modal/ModalPage';
|
||||||
import React, {useContext, useEffect, useState} from 'react';
|
import React, {useEffect, useState} from 'react';
|
||||||
import Select from '../../../../admin-x-ds/global/form/Select';
|
import Select from '../../../../admin-x-ds/global/form/Select';
|
||||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||||
import {SettingsContext} from '../../../providers/SettingsProvider';
|
|
||||||
import {getHomepageUrl, getPaidActiveTiers} from '../../../../utils/helpers';
|
import {getHomepageUrl, getPaidActiveTiers} from '../../../../utils/helpers';
|
||||||
import {useTiers} from '../../../providers/ServiceProvider';
|
import {useGlobalData} from '../../../providers/DataProvider';
|
||||||
|
|
||||||
interface PortalLinkPrefs {
|
interface PortalLinkPrefs {
|
||||||
name: string;
|
name: string;
|
||||||
@ -39,8 +38,7 @@ const PortalLink: React.FC<PortalLinkPrefs> = ({name, value}) => {
|
|||||||
const PortalLinks: React.FC = () => {
|
const PortalLinks: React.FC = () => {
|
||||||
const [isDataAttributes, setIsDataAttributes] = useState(false);
|
const [isDataAttributes, setIsDataAttributes] = useState(false);
|
||||||
const [selectedTier, setSelectedTier] = useState('');
|
const [selectedTier, setSelectedTier] = useState('');
|
||||||
const {siteData} = useContext(SettingsContext);
|
const {siteData, tiers: allTiers} = useGlobalData();
|
||||||
const {data: allTiers} = useTiers();
|
|
||||||
const tiers = getPaidActiveTiers(allTiers);
|
const tiers = getPaidActiveTiers(allTiers);
|
||||||
|
|
||||||
const toggleIsDataAttributes = () => {
|
const toggleIsDataAttributes = () => {
|
||||||
@ -104,4 +102,4 @@ const PortalLinks: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PortalLinks;
|
export default PortalLinks;
|
||||||
|
@ -3,16 +3,17 @@ import ConfirmationModal from '../../../../admin-x-ds/global/modal/ConfirmationM
|
|||||||
import LookAndFeel from './LookAndFeel';
|
import LookAndFeel from './LookAndFeel';
|
||||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
||||||
import PortalPreview from './PortalPreview';
|
import PortalPreview from './PortalPreview';
|
||||||
import React, {useContext, useState} from 'react';
|
import React, {useState} from 'react';
|
||||||
import SignupOptions from './SignupOptions';
|
import SignupOptions from './SignupOptions';
|
||||||
import TabView, {Tab} from '../../../../admin-x-ds/global/TabView';
|
import TabView, {Tab} from '../../../../admin-x-ds/global/TabView';
|
||||||
import useForm, {Dirtyable} from '../../../../hooks/useForm';
|
import useForm, {Dirtyable} from '../../../../hooks/useForm';
|
||||||
import useRouting from '../../../../hooks/useRouting';
|
import useRouting from '../../../../hooks/useRouting';
|
||||||
|
import useSettings from '../../../../hooks/useSettings';
|
||||||
import {PreviewModalContent} from '../../../../admin-x-ds/global/modal/PreviewModal';
|
import {PreviewModalContent} from '../../../../admin-x-ds/global/modal/PreviewModal';
|
||||||
import {Setting, SettingValue, Tier} from '../../../../types/api';
|
import {Setting, SettingValue, Tier} from '../../../../types/api';
|
||||||
import {SettingsContext} from '../../../providers/SettingsProvider';
|
|
||||||
import {fullEmailAddress, getPaidActiveTiers} from '../../../../utils/helpers';
|
import {fullEmailAddress, getPaidActiveTiers} from '../../../../utils/helpers';
|
||||||
import {useTiers} from '../../../providers/ServiceProvider';
|
import {useEditTier} from '../../../../utils/api/tiers';
|
||||||
|
import {useGlobalData} from '../../../providers/DataProvider';
|
||||||
|
|
||||||
const Sidebar: React.FC<{
|
const Sidebar: React.FC<{
|
||||||
localSettings: Setting[]
|
localSettings: Setting[]
|
||||||
@ -66,10 +67,12 @@ const PortalModal: React.FC = () => {
|
|||||||
|
|
||||||
const [selectedPreviewTab, setSelectedPreviewTab] = useState('signup');
|
const [selectedPreviewTab, setSelectedPreviewTab] = useState('signup');
|
||||||
|
|
||||||
const {settings, saveSettings, siteData} = useContext(SettingsContext);
|
const {settings, saveSettings, siteData} = useSettings();
|
||||||
const {data: allTiers, update: updateTiers} = useTiers();
|
const {tiers: allTiers} = useGlobalData();
|
||||||
const tiers = getPaidActiveTiers(allTiers);
|
const tiers = getPaidActiveTiers(allTiers);
|
||||||
|
|
||||||
|
const {mutateAsync: editTier} = useEditTier();
|
||||||
|
|
||||||
const {formState, saveState, handleSave, updateForm} = useForm({
|
const {formState, saveState, handleSave, updateForm} = useForm({
|
||||||
initialState: {
|
initialState: {
|
||||||
settings: settings as Dirtyable<Setting>[],
|
settings: settings as Dirtyable<Setting>[],
|
||||||
@ -77,7 +80,8 @@ const PortalModal: React.FC = () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
onSave: async () => {
|
onSave: async () => {
|
||||||
await updateTiers(...formState.tiers.filter(tier => tier.dirty));
|
await Promise.all(formState.tiers.filter(({dirty}) => dirty).map(tier => editTier(tier)));
|
||||||
|
|
||||||
const {meta, settings: currentSettings} = await saveSettings(formState.settings.filter(setting => setting.dirty));
|
const {meta, settings: currentSettings} = await saveSettings(formState.settings.filter(setting => setting.dirty));
|
||||||
|
|
||||||
if (meta?.sent_email_verification) {
|
if (meta?.sent_email_verification) {
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import CheckboxGroup from '../../../../admin-x-ds/global/form/CheckboxGroup';
|
import CheckboxGroup from '../../../../admin-x-ds/global/form/CheckboxGroup';
|
||||||
import Form from '../../../../admin-x-ds/global/form/Form';
|
import Form from '../../../../admin-x-ds/global/form/Form';
|
||||||
import HtmlField from '../../../../admin-x-ds/global/form/HtmlField';
|
import HtmlField, { EditorConfig } from '../../../../admin-x-ds/global/form/HtmlField';
|
||||||
import React, {useContext, useEffect, useMemo} from 'react';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
||||||
import {CheckboxProps} from '../../../../admin-x-ds/global/form/Checkbox';
|
import { CheckboxProps } from '../../../../admin-x-ds/global/form/Checkbox';
|
||||||
import {Setting, SettingValue, Tier} from '../../../../types/api';
|
import { Setting, SettingValue, Tier } from '../../../../types/api';
|
||||||
import {SettingsContext} from '../../../providers/SettingsProvider';
|
import { checkStripeEnabled, getSettingValues } from '../../../../utils/helpers';
|
||||||
import {checkStripeEnabled, getSettingValues} from '../../../../utils/helpers';
|
import { useGlobalData } from '../../../providers/DataProvider';
|
||||||
|
|
||||||
const SignupOptions: React.FC<{
|
const SignupOptions: React.FC<{
|
||||||
localSettings: Setting[]
|
localSettings: Setting[]
|
||||||
@ -16,7 +16,7 @@ const SignupOptions: React.FC<{
|
|||||||
errors: Record<string, string | undefined>
|
errors: Record<string, string | undefined>
|
||||||
setError: (key: string, error: string | undefined) => void
|
setError: (key: string, error: string | undefined) => void
|
||||||
}> = ({localSettings, updateSetting, localTiers, updateTier, errors, setError}) => {
|
}> = ({localSettings, updateSetting, localTiers, updateTier, errors, setError}) => {
|
||||||
const {config} = useContext(SettingsContext);
|
const {config} = useGlobalData();
|
||||||
|
|
||||||
const [membersSignupAccess, portalName, portalSignupTermsHtml, portalSignupCheckboxRequired, portalPlansJson] = getSettingValues(
|
const [membersSignupAccess, portalName, portalSignupTermsHtml, portalSignupCheckboxRequired, portalPlansJson] = getSettingValues(
|
||||||
localSettings, ['members_signup_access', 'portal_name', 'portal_signup_terms_html', 'portal_signup_checkbox_required', 'portal_plans']
|
localSettings, ['members_signup_access', 'portal_name', 'portal_signup_terms_html', 'portal_signup_checkbox_required', 'portal_plans']
|
||||||
@ -119,7 +119,7 @@ const SignupOptions: React.FC<{
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<HtmlField
|
<HtmlField
|
||||||
config={config as { editor: any }}
|
config={config as EditorConfig}
|
||||||
error={Boolean(errors.portal_signup_terms_html)}
|
error={Boolean(errors.portal_signup_terms_html)}
|
||||||
hint={errors.portal_signup_terms_html || <>Recommended: <strong>115</strong> characters. You've used <strong className="text-green">{signupTermsLength}</strong></>}
|
hint={errors.portal_signup_terms_html || <>Recommended: <strong>115</strong> characters. You've used <strong className="text-green">{signupTermsLength}</strong></>}
|
||||||
nodes='MINIMAL_NODES'
|
nodes='MINIMAL_NODES'
|
||||||
|
@ -3,8 +3,8 @@ import Form from '../../../../admin-x-ds/global/form/Form';
|
|||||||
import Heading from '../../../../admin-x-ds/global/Heading';
|
import Heading from '../../../../admin-x-ds/global/Heading';
|
||||||
import Icon from '../../../../admin-x-ds/global/Icon';
|
import Icon from '../../../../admin-x-ds/global/Icon';
|
||||||
import Modal from '../../../../admin-x-ds/global/modal/Modal';
|
import Modal from '../../../../admin-x-ds/global/modal/Modal';
|
||||||
import NiceModal, {useModal} from '@ebay/nice-modal-react';
|
import NiceModal, { useModal } from '@ebay/nice-modal-react';
|
||||||
import React, {useState} from 'react';
|
import React, { useState } from 'react';
|
||||||
import Select from '../../../../admin-x-ds/global/form/Select';
|
import Select from '../../../../admin-x-ds/global/form/Select';
|
||||||
import SortableList from '../../../../admin-x-ds/global/SortableList';
|
import SortableList from '../../../../admin-x-ds/global/SortableList';
|
||||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||||
@ -14,12 +14,12 @@ import useForm from '../../../../hooks/useForm';
|
|||||||
import useRouting from '../../../../hooks/useRouting';
|
import useRouting from '../../../../hooks/useRouting';
|
||||||
import useSettingGroup from '../../../../hooks/useSettingGroup';
|
import useSettingGroup from '../../../../hooks/useSettingGroup';
|
||||||
import useSortableIndexedList from '../../../../hooks/useSortableIndexedList';
|
import useSortableIndexedList from '../../../../hooks/useSortableIndexedList';
|
||||||
import {Tier} from '../../../../types/api';
|
import { Tier } from '../../../../types/api';
|
||||||
import {currencies, currencyFromDecimal, currencyGroups, currencyToDecimal, getSymbol} from '../../../../utils/currency';
|
import { currencies, currencyFromDecimal, currencyGroups, currencyToDecimal, getSymbol } from '../../../../utils/currency';
|
||||||
import {getSettingValues} from '../../../../utils/helpers';
|
import { getSettingValues } from '../../../../utils/helpers';
|
||||||
import {showToast} from '../../../../admin-x-ds/global/Toast';
|
import { showToast } from '../../../../admin-x-ds/global/Toast';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import {useTiers} from '../../../providers/ServiceProvider';
|
import { useAddTier, useEditTier } from '../../../../utils/api/tiers';
|
||||||
|
|
||||||
interface TierDetailModalProps {
|
interface TierDetailModalProps {
|
||||||
tier?: Tier
|
tier?: Tier
|
||||||
@ -36,7 +36,8 @@ const TierDetailModal: React.FC<TierDetailModalProps> = ({tier}) => {
|
|||||||
|
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
const {updateRoute} = useRouting();
|
const {updateRoute} = useRouting();
|
||||||
const {update: updateTier, create: createTier} = useTiers();
|
const {mutateAsync: updateTier} = useEditTier();
|
||||||
|
const {mutateAsync: createTier} = useAddTier();
|
||||||
const [hasFreeTrial, setHasFreeTrial] = React.useState(!!tier?.trial_days);
|
const [hasFreeTrial, setHasFreeTrial] = React.useState(!!tier?.trial_days);
|
||||||
const {localSettings} = useSettingGroup();
|
const {localSettings} = useSettingGroup();
|
||||||
const siteTitle = getSettingValues(localSettings, ['title']) as string[];
|
const siteTitle = getSettingValues(localSettings, ['title']) as string[];
|
||||||
|
@ -5,27 +5,24 @@ import NoValueLabel from '../../../../admin-x-ds/global/NoValueLabel';
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import TierDetailModal from './TierDetailModal';
|
import TierDetailModal from './TierDetailModal';
|
||||||
import useRouting from '../../../../hooks/useRouting';
|
import useRouting from '../../../../hooks/useRouting';
|
||||||
import {Tier} from '../../../../types/api';
|
import { Tier } from '../../../../types/api';
|
||||||
import {currencyToDecimal, getSymbol} from '../../../../utils/currency';
|
import { currencyToDecimal, getSymbol } from '../../../../utils/currency';
|
||||||
import {numberWithCommas} from '../../../../utils/helpers';
|
import { numberWithCommas } from '../../../../utils/helpers';
|
||||||
|
import { useEditTier } from '../../../../utils/api/tiers';
|
||||||
|
|
||||||
interface TiersListProps {
|
interface TiersListProps {
|
||||||
tab?: string;
|
tab?: string;
|
||||||
tiers: Tier[];
|
tiers: Tier[];
|
||||||
updateTier: (data: Tier) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TierCardProps {
|
interface TierCardProps {
|
||||||
tier: Tier;
|
tier: Tier;
|
||||||
updateTier: (data: Tier) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardContainerClasses = 'group flex min-h-[200px] flex-col items-start justify-between gap-4 self-stretch rounded-sm border border-grey-300 p-4 transition-all hover:border-grey-400';
|
const cardContainerClasses = 'group flex min-h-[200px] flex-col items-start justify-between gap-4 self-stretch rounded-sm border border-grey-300 p-4 transition-all hover:border-grey-400';
|
||||||
|
|
||||||
const TierCard: React.FC<TierCardProps> = ({
|
const TierCard: React.FC<TierCardProps> = ({tier}) => {
|
||||||
tier,
|
const {mutateAsync: updateTier} = useEditTier();
|
||||||
updateTier
|
|
||||||
}) => {
|
|
||||||
const currency = tier?.currency || 'USD';
|
const currency = tier?.currency || 'USD';
|
||||||
const currencySymbol = currency ? getSymbol(currency) : '$';
|
const currencySymbol = currency ? getSymbol(currency) : '$';
|
||||||
|
|
||||||
@ -60,8 +57,7 @@ const TierCard: React.FC<TierCardProps> = ({
|
|||||||
|
|
||||||
const TiersList: React.FC<TiersListProps> = ({
|
const TiersList: React.FC<TiersListProps> = ({
|
||||||
tab,
|
tab,
|
||||||
tiers,
|
tiers
|
||||||
updateTier
|
|
||||||
}) => {
|
}) => {
|
||||||
const {updateRoute} = useRouting();
|
const {updateRoute} = useRouting();
|
||||||
const openTierModal = () => {
|
const openTierModal = () => {
|
||||||
@ -79,7 +75,7 @@ const TiersList: React.FC<TiersListProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div className='mt-4 grid grid-cols-3 gap-4'>
|
<div className='mt-4 grid grid-cols-3 gap-4'>
|
||||||
{tiers.map((tier) => {
|
{tiers.map((tier) => {
|
||||||
return <TierCard tier={tier} updateTier={updateTier} />;
|
return <TierCard tier={tier} />;
|
||||||
})}
|
})}
|
||||||
{tab === 'active-tiers' && (
|
{tab === 'active-tiers' && (
|
||||||
<button className={`${cardContainerClasses} group cursor-pointer`} type='button' onClick={() => {
|
<button className={`${cardContainerClasses} group cursor-pointer`} type='button' onClick={() => {
|
||||||
|
@ -1,20 +1,21 @@
|
|||||||
import BrandSettings, {BrandSettingValues} from './designAndBranding/BrandSettings';
|
import BrandSettings, { BrandSettingValues } from './designAndBranding/BrandSettings';
|
||||||
// import Button from '../../../admin-x-ds/global/Button';
|
// import Button from '../../../admin-x-ds/global/Button';
|
||||||
// import ChangeThemeModal from './ThemeModal';
|
// import ChangeThemeModal from './ThemeModal';
|
||||||
import Icon from '../../../admin-x-ds/global/Icon';
|
import Icon from '../../../admin-x-ds/global/Icon';
|
||||||
import NiceModal, {NiceModalHandler, useModal} from '@ebay/nice-modal-react';
|
import NiceModal, { NiceModalHandler, useModal } from '@ebay/nice-modal-react';
|
||||||
import React, {useContext, useEffect, useState} from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import StickyFooter from '../../../admin-x-ds/global/StickyFooter';
|
import StickyFooter from '../../../admin-x-ds/global/StickyFooter';
|
||||||
import TabView, {Tab} from '../../../admin-x-ds/global/TabView';
|
import TabView, { Tab } from '../../../admin-x-ds/global/TabView';
|
||||||
import ThemePreview from './designAndBranding/ThemePreview';
|
import ThemePreview from './designAndBranding/ThemePreview';
|
||||||
import ThemeSettings from './designAndBranding/ThemeSettings';
|
import ThemeSettings from './designAndBranding/ThemeSettings';
|
||||||
import useForm from '../../../hooks/useForm';
|
import useForm from '../../../hooks/useForm';
|
||||||
import useRouting from '../../../hooks/useRouting';
|
import useRouting from '../../../hooks/useRouting';
|
||||||
import {CustomThemeSetting, Post, Setting, SettingValue} from '../../../types/api';
|
import useSettings from '../../../hooks/useSettings';
|
||||||
import {PreviewModalContent} from '../../../admin-x-ds/global/modal/PreviewModal';
|
import { CustomThemeSetting, Setting, SettingValue } from '../../../types/api';
|
||||||
import {ServicesContext} from '../../providers/ServiceProvider';
|
import { PreviewModalContent } from '../../../admin-x-ds/global/modal/PreviewModal';
|
||||||
import {SettingsContext} from '../../providers/SettingsProvider';
|
import { getHomepageUrl, getSettingValues } from '../../../utils/helpers';
|
||||||
import {getHomepageUrl, getSettingValues} from '../../../utils/helpers';
|
import { useBrowseCustomThemeSettings, useEditCustomThemeSettings } from '../../../utils/api/customThemeSettings';
|
||||||
|
import { useBrowsePosts } from '../../../utils/api/posts';
|
||||||
|
|
||||||
const Sidebar: React.FC<{
|
const Sidebar: React.FC<{
|
||||||
brandSettings: BrandSettingValues
|
brandSettings: BrandSettingValues
|
||||||
@ -78,19 +79,18 @@ const Sidebar: React.FC<{
|
|||||||
const DesignModal: React.FC = () => {
|
const DesignModal: React.FC = () => {
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
|
|
||||||
const {api} = useContext(ServicesContext);
|
const {settings, siteData, saveSettings} = useSettings();
|
||||||
const {settings, siteData, saveSettings} = useContext(SettingsContext);
|
const {data: {posts: [latestPost]} = {posts: []}} = useBrowsePosts({
|
||||||
const [themeSettings, setThemeSettings] = useState<Array<CustomThemeSetting>>([]);
|
filter: 'status:published',
|
||||||
const [latestPost, setLatestPost] = useState<Post | null>(null);
|
order: 'published_at DESC',
|
||||||
|
limit: '1',
|
||||||
|
fields: 'id,url'
|
||||||
|
});
|
||||||
|
const {data: themeSettings} = useBrowseCustomThemeSettings();
|
||||||
|
const {mutateAsync: editThemeSettings} = useEditCustomThemeSettings();
|
||||||
const [selectedPreviewTab, setSelectedPreviewTab] = useState('homepage');
|
const [selectedPreviewTab, setSelectedPreviewTab] = useState('homepage');
|
||||||
const {updateRoute} = useRouting();
|
const {updateRoute} = useRouting();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.latestPost.browse().then((response) => {
|
|
||||||
setLatestPost(response.posts[0]);
|
|
||||||
});
|
|
||||||
}, [api]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
formState,
|
formState,
|
||||||
saveState,
|
saveState,
|
||||||
@ -100,12 +100,11 @@ const DesignModal: React.FC = () => {
|
|||||||
} = useForm({
|
} = useForm({
|
||||||
initialState: {
|
initialState: {
|
||||||
settings: settings as Array<Setting & { dirty?: boolean }>,
|
settings: settings as Array<Setting & { dirty?: boolean }>,
|
||||||
themeSettings: themeSettings as Array<CustomThemeSetting & { dirty?: boolean }>
|
themeSettings: (themeSettings?.custom_theme_settings || []) as Array<CustomThemeSetting & { dirty?: boolean }>
|
||||||
},
|
},
|
||||||
onSave: async () => {
|
onSave: async () => {
|
||||||
if (formState.themeSettings.some(setting => setting.dirty)) {
|
if (formState.themeSettings.some(setting => setting.dirty)) {
|
||||||
const response = await api.customThemeSettings.edit(formState.themeSettings);
|
const response = await editThemeSettings(formState.themeSettings);
|
||||||
setThemeSettings(response.custom_theme_settings);
|
|
||||||
updateForm(state => ({...state, themeSettings: response.custom_theme_settings}));
|
updateForm(state => ({...state, themeSettings: response.custom_theme_settings}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,11 +116,10 @@ const DesignModal: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.customThemeSettings.browse().then((response) => {
|
if (themeSettings) {
|
||||||
setThemeSettings(response.custom_theme_settings);
|
setFormState(state => ({...state, themeSettings: themeSettings.custom_theme_settings}));
|
||||||
setFormState(state => ({...state, themeSettings: response.custom_theme_settings}));
|
}
|
||||||
});
|
}, [setFormState, themeSettings]);
|
||||||
}, [api, updateForm, setFormState]);
|
|
||||||
|
|
||||||
const updateBrandSetting = (key: string, value: SettingValue) => {
|
const updateBrandSetting = (key: string, value: SettingValue) => {
|
||||||
updateForm(state => ({...state, settings: state.settings.map(setting => (
|
updateForm(state => ({...state, settings: state.settings.map(setting => (
|
||||||
|
@ -4,19 +4,17 @@ import Button from '../../../admin-x-ds/global/Button';
|
|||||||
import ConfirmationModal from '../../../admin-x-ds/global/modal/ConfirmationModal';
|
import ConfirmationModal from '../../../admin-x-ds/global/modal/ConfirmationModal';
|
||||||
import FileUpload from '../../../admin-x-ds/global/form/FileUpload';
|
import FileUpload from '../../../admin-x-ds/global/form/FileUpload';
|
||||||
import Modal from '../../../admin-x-ds/global/modal/Modal';
|
import Modal from '../../../admin-x-ds/global/modal/Modal';
|
||||||
import NiceModal, {NiceModalHandler, useModal} from '@ebay/nice-modal-react';
|
import NiceModal, { NiceModalHandler, useModal } from '@ebay/nice-modal-react';
|
||||||
import OfficialThemes from './theme/OfficialThemes';
|
import OfficialThemes from './theme/OfficialThemes';
|
||||||
import PageHeader from '../../../admin-x-ds/global/layout/PageHeader';
|
import PageHeader from '../../../admin-x-ds/global/layout/PageHeader';
|
||||||
import React, {useState} from 'react';
|
import React, { useState } from 'react';
|
||||||
import TabView from '../../../admin-x-ds/global/TabView';
|
import TabView from '../../../admin-x-ds/global/TabView';
|
||||||
import ThemeInstalledModal from './theme/ThemeInstalledModal';
|
import ThemeInstalledModal from './theme/ThemeInstalledModal';
|
||||||
import ThemePreview from './theme/ThemePreview';
|
import ThemePreview from './theme/ThemePreview';
|
||||||
import useRouting from '../../../hooks/useRouting';
|
import useRouting from '../../../hooks/useRouting';
|
||||||
import {API} from '../../../utils/api';
|
import { OfficialTheme } from '../../../models/themes';
|
||||||
import {OfficialTheme} from '../../../models/themes';
|
import { Theme } from '../../../types/api';
|
||||||
import {Theme} from '../../../types/api';
|
import { useBrowseThemes, useInstallTheme, useUploadTheme } from '../../../utils/api/themes';
|
||||||
import {useApi} from '../../providers/ServiceProvider';
|
|
||||||
import {useThemes} from '../../../hooks/useThemes';
|
|
||||||
|
|
||||||
interface ThemeToolbarProps {
|
interface ThemeToolbarProps {
|
||||||
selectedTheme: OfficialTheme|null;
|
selectedTheme: OfficialTheme|null;
|
||||||
@ -25,7 +23,6 @@ interface ThemeToolbarProps {
|
|||||||
setSelectedTheme: (theme: OfficialTheme|null) => void;
|
setSelectedTheme: (theme: OfficialTheme|null) => void;
|
||||||
modal: NiceModalHandler<Record<string, unknown>>;
|
modal: NiceModalHandler<Record<string, unknown>>;
|
||||||
themes: Theme[];
|
themes: Theme[];
|
||||||
setThemes: React.Dispatch<React.SetStateAction<Theme[]>>;
|
|
||||||
setPreviewMode: (mode: string) => void;
|
setPreviewMode: (mode: string) => void;
|
||||||
previewMode: string;
|
previewMode: string;
|
||||||
}
|
}
|
||||||
@ -34,92 +31,68 @@ interface ThemeModalContentProps {
|
|||||||
onSelectTheme: (theme: OfficialTheme|null) => void;
|
onSelectTheme: (theme: OfficialTheme|null) => void;
|
||||||
currentTab: string;
|
currentTab: string;
|
||||||
themes: Theme[];
|
themes: Theme[];
|
||||||
setThemes: React.Dispatch<React.SetStateAction<Theme[]>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addThemeToList(theme: Theme, themes: Theme[]): Theme[] {
|
|
||||||
const existingTheme = themes.find(t => t.name === theme.name);
|
|
||||||
if (existingTheme) {
|
|
||||||
return themes.map((t) => {
|
|
||||||
if (t.name === theme.name) {
|
|
||||||
return theme;
|
|
||||||
}
|
|
||||||
return t;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return [...themes, theme];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleThemeUpload({
|
|
||||||
api,
|
|
||||||
file,
|
|
||||||
setThemes,
|
|
||||||
onActivate
|
|
||||||
}: {
|
|
||||||
api: API;
|
|
||||||
file: File;
|
|
||||||
setThemes: React.Dispatch<React.SetStateAction<Theme[]>>;
|
|
||||||
onActivate?: () => void
|
|
||||||
}) {
|
|
||||||
const data = await api.themes.upload({file});
|
|
||||||
const uploadedTheme = data.themes[0];
|
|
||||||
|
|
||||||
setThemes((_themes: Theme[]) => {
|
|
||||||
return addThemeToList(uploadedTheme, _themes);
|
|
||||||
});
|
|
||||||
|
|
||||||
let title = 'Upload successful';
|
|
||||||
let prompt = <>
|
|
||||||
<strong>{uploadedTheme.name}</strong> uploaded successfully.
|
|
||||||
</>;
|
|
||||||
|
|
||||||
if (!uploadedTheme.active) {
|
|
||||||
prompt = <>
|
|
||||||
{prompt}{' '}
|
|
||||||
Do you want to activate it now?
|
|
||||||
</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (uploadedTheme.errors?.length || uploadedTheme.warnings?.length) {
|
|
||||||
const hasErrors = uploadedTheme.errors?.length;
|
|
||||||
|
|
||||||
title = `Upload successful with ${hasErrors ? 'errors' : 'warnings'}`;
|
|
||||||
prompt = <>
|
|
||||||
The theme <strong>"{uploadedTheme.name}"</strong> was installed successfully but we detected some {hasErrors ? 'errors' : 'warnings'}.
|
|
||||||
</>;
|
|
||||||
|
|
||||||
if (!uploadedTheme.active) {
|
|
||||||
prompt = <>
|
|
||||||
{prompt}
|
|
||||||
You are still able to activate and use the theme but it is recommended to fix these {hasErrors ? 'errors' : 'warnings'} before you do so.
|
|
||||||
</>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NiceModal.show(ThemeInstalledModal, {
|
|
||||||
title,
|
|
||||||
prompt,
|
|
||||||
installedTheme: uploadedTheme,
|
|
||||||
setThemes,
|
|
||||||
onActivate: onActivate
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
||||||
currentTab,
|
currentTab,
|
||||||
setCurrentTab,
|
setCurrentTab,
|
||||||
modal,
|
modal,
|
||||||
themes,
|
themes
|
||||||
setThemes
|
|
||||||
}) => {
|
}) => {
|
||||||
const {updateRoute} = useRouting();
|
const {updateRoute} = useRouting();
|
||||||
const api = useApi();
|
const {mutateAsync: uploadTheme} = useUploadTheme();
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
updateRoute('design/edit');
|
updateRoute('design/edit');
|
||||||
modal.remove();
|
modal.remove();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleThemeUpload = async ({
|
||||||
|
file,
|
||||||
|
onActivate
|
||||||
|
}: {
|
||||||
|
file: File;
|
||||||
|
onActivate?: () => void
|
||||||
|
}) => {
|
||||||
|
const data = await uploadTheme({file});
|
||||||
|
const uploadedTheme = data.themes[0];
|
||||||
|
|
||||||
|
let title = 'Upload successful';
|
||||||
|
let prompt = <>
|
||||||
|
<strong>{uploadedTheme.name}</strong> uploaded successfully.
|
||||||
|
</>;
|
||||||
|
|
||||||
|
if (!uploadedTheme.active) {
|
||||||
|
prompt = <>
|
||||||
|
{prompt}{' '}
|
||||||
|
Do you want to activate it now?
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadedTheme.errors?.length || uploadedTheme.warnings?.length) {
|
||||||
|
const hasErrors = uploadedTheme.errors?.length;
|
||||||
|
|
||||||
|
title = `Upload successful with ${hasErrors ? 'errors' : 'warnings'}`;
|
||||||
|
prompt = <>
|
||||||
|
The theme <strong>"{uploadedTheme.name}"</strong> was installed successfully but we detected some {hasErrors ? 'errors' : 'warnings'}.
|
||||||
|
</>;
|
||||||
|
|
||||||
|
if (!uploadedTheme.active) {
|
||||||
|
prompt = <>
|
||||||
|
{prompt}
|
||||||
|
You are still able to activate and use the theme but it is recommended to fix these {hasErrors ? 'errors' : 'warnings'} before you do so.
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NiceModal.show(ThemeInstalledModal, {
|
||||||
|
title,
|
||||||
|
prompt,
|
||||||
|
installedTheme: uploadedTheme,
|
||||||
|
onActivate: onActivate
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const left =
|
const left =
|
||||||
<Breadcrumbs
|
<Breadcrumbs
|
||||||
items={[
|
items={[
|
||||||
@ -160,14 +133,14 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
|||||||
okRunningLabel: 'Overwriting...',
|
okRunningLabel: 'Overwriting...',
|
||||||
okColor: 'red',
|
okColor: 'red',
|
||||||
onOk: async (confirmModal) => {
|
onOk: async (confirmModal) => {
|
||||||
await handleThemeUpload({api, file, setThemes, onActivate: onClose});
|
await handleThemeUpload({file, onActivate: onClose});
|
||||||
setCurrentTab('installed');
|
setCurrentTab('installed');
|
||||||
confirmModal?.remove();
|
confirmModal?.remove();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setCurrentTab('installed');
|
setCurrentTab('installed');
|
||||||
handleThemeUpload({api, file, setThemes, onActivate: onClose});
|
handleThemeUpload({file, onActivate: onClose});
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<Button color='black' label='Upload theme' tag='div' />
|
<Button color='black' label='Upload theme' tag='div' />
|
||||||
@ -184,8 +157,7 @@ const ThemeToolbar: React.FC<ThemeToolbarProps> = ({
|
|||||||
const ThemeModalContent: React.FC<ThemeModalContentProps> = ({
|
const ThemeModalContent: React.FC<ThemeModalContentProps> = ({
|
||||||
currentTab,
|
currentTab,
|
||||||
onSelectTheme,
|
onSelectTheme,
|
||||||
themes,
|
themes
|
||||||
setThemes
|
|
||||||
}) => {
|
}) => {
|
||||||
switch (currentTab) {
|
switch (currentTab) {
|
||||||
case 'official':
|
case 'official':
|
||||||
@ -194,10 +166,7 @@ const ThemeModalContent: React.FC<ThemeModalContentProps> = ({
|
|||||||
);
|
);
|
||||||
case 'installed':
|
case 'installed':
|
||||||
return (
|
return (
|
||||||
<AdvancedThemeSettings
|
<AdvancedThemeSettings themes={themes} />
|
||||||
setThemes={setThemes}
|
|
||||||
themes={themes}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -211,27 +180,27 @@ const ChangeThemeModal = NiceModal.create(() => {
|
|||||||
const {updateRoute} = useRouting();
|
const {updateRoute} = useRouting();
|
||||||
|
|
||||||
const modal = useModal();
|
const modal = useModal();
|
||||||
const {themes, setThemes} = useThemes();
|
const {data: {themes} = {}} = useBrowseThemes();
|
||||||
const api = useApi();
|
const {mutateAsync: installTheme} = useInstallTheme();
|
||||||
|
|
||||||
const onSelectTheme = (theme: OfficialTheme|null) => {
|
const onSelectTheme = (theme: OfficialTheme|null) => {
|
||||||
setSelectedTheme(theme);
|
setSelectedTheme(theme);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!themes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let installedTheme;
|
let installedTheme;
|
||||||
let onInstall;
|
let onInstall;
|
||||||
if (selectedTheme) {
|
if (selectedTheme) {
|
||||||
installedTheme = themes.find(theme => theme.name.toLowerCase() === selectedTheme!.name.toLowerCase());
|
installedTheme = themes.find(theme => theme.name.toLowerCase() === selectedTheme!.name.toLowerCase());
|
||||||
onInstall = async () => {
|
onInstall = async () => {
|
||||||
setInstalling(true);
|
setInstalling(true);
|
||||||
const data = await api.themes.install(selectedTheme.ref);
|
const data = await installTheme(selectedTheme.ref);
|
||||||
setInstalling(false);
|
setInstalling(false);
|
||||||
|
|
||||||
const newlyInstalledTheme = data.themes[0];
|
const newlyInstalledTheme = data.themes[0];
|
||||||
setThemes([
|
|
||||||
...themes.map(theme => ({...theme, active: false})),
|
|
||||||
newlyInstalledTheme
|
|
||||||
]);
|
|
||||||
|
|
||||||
let title = 'Success';
|
let title = 'Success';
|
||||||
let prompt = <>
|
let prompt = <>
|
||||||
@ -265,7 +234,6 @@ const ChangeThemeModal = NiceModal.create(() => {
|
|||||||
title,
|
title,
|
||||||
prompt,
|
prompt,
|
||||||
installedTheme: newlyInstalledTheme,
|
installedTheme: newlyInstalledTheme,
|
||||||
setThemes,
|
|
||||||
onActivate: () => {
|
onActivate: () => {
|
||||||
updateRoute('design/edit');
|
updateRoute('design/edit');
|
||||||
modal.remove();
|
modal.remove();
|
||||||
@ -312,12 +280,10 @@ const ChangeThemeModal = NiceModal.create(() => {
|
|||||||
setCurrentTab={setCurrentTab}
|
setCurrentTab={setCurrentTab}
|
||||||
setPreviewMode={setPreviewMode}
|
setPreviewMode={setPreviewMode}
|
||||||
setSelectedTheme={setSelectedTheme}
|
setSelectedTheme={setSelectedTheme}
|
||||||
setThemes={setThemes}
|
|
||||||
themes={themes}
|
themes={themes}
|
||||||
/>
|
/>
|
||||||
<ThemeModalContent
|
<ThemeModalContent
|
||||||
currentTab={currentTab}
|
currentTab={currentTab}
|
||||||
setThemes={setThemes}
|
|
||||||
themes={themes}
|
themes={themes}
|
||||||
onSelectTheme={onSelectTheme}
|
onSelectTheme={onSelectTheme}
|
||||||
/>
|
/>
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import Heading from '../../../../admin-x-ds/global/Heading';
|
import Heading from '../../../../admin-x-ds/global/Heading';
|
||||||
import Hint from '../../../../admin-x-ds/global/Hint';
|
import Hint from '../../../../admin-x-ds/global/Hint';
|
||||||
import ImageUpload from '../../../../admin-x-ds/global/form/ImageUpload';
|
import ImageUpload from '../../../../admin-x-ds/global/form/ImageUpload';
|
||||||
import React, {useContext} from 'react';
|
import React from 'react';
|
||||||
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
|
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
|
||||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||||
import {ServicesContext} from '../../../providers/ServiceProvider';
|
|
||||||
import {SettingValue} from '../../../../types/api';
|
import {SettingValue} from '../../../../types/api';
|
||||||
|
import {getImageUrl, useUploadImage} from '../../../../utils/api/images';
|
||||||
|
|
||||||
export interface BrandSettingValues {
|
export interface BrandSettingValues {
|
||||||
description: string
|
description: string
|
||||||
@ -16,7 +16,7 @@ export interface BrandSettingValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key: string, value: SettingValue) => void }> = ({values,updateSetting}) => {
|
const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key: string, value: SettingValue) => void }> = ({values,updateSetting}) => {
|
||||||
const {fileService} = useContext(ServicesContext);
|
const {mutateAsync: uploadImage} = useUploadImage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-7'>
|
<div className='mt-7'>
|
||||||
@ -59,7 +59,7 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
|
|||||||
width={values.icon ? '66px' : '150px'}
|
width={values.icon ? '66px' : '150px'}
|
||||||
onDelete={() => updateSetting('icon', null)}
|
onDelete={() => updateSetting('icon', null)}
|
||||||
onUpload={async (file) => {
|
onUpload={async (file) => {
|
||||||
updateSetting('icon', await fileService!.uploadImage(file));
|
updateSetting('icon', getImageUrl(await uploadImage({file})));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Upload icon
|
Upload icon
|
||||||
@ -77,7 +77,7 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
|
|||||||
imageURL={values.logo || ''}
|
imageURL={values.logo || ''}
|
||||||
onDelete={() => updateSetting('logo', null)}
|
onDelete={() => updateSetting('logo', null)}
|
||||||
onUpload={async (file) => {
|
onUpload={async (file) => {
|
||||||
updateSetting('logo', await fileService!.uploadImage(file));
|
updateSetting('logo', getImageUrl(await uploadImage({file})));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Upload logo
|
Upload logo
|
||||||
@ -92,7 +92,7 @@ const BrandSettings: React.FC<{ values: BrandSettingValues, updateSetting: (key:
|
|||||||
imageURL={values.coverImage || ''}
|
imageURL={values.coverImage || ''}
|
||||||
onDelete={() => updateSetting('cover_image', null)}
|
onDelete={() => updateSetting('cover_image', null)}
|
||||||
onUpload={async (file) => {
|
onUpload={async (file) => {
|
||||||
updateSetting('cover_image', await fileService!.uploadImage(file));
|
updateSetting('cover_image', getImageUrl(await uploadImage({file})));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Upload cover
|
Upload cover
|
||||||
|
@ -1,23 +1,23 @@
|
|||||||
import Heading from '../../../../admin-x-ds/global/Heading';
|
import Heading from '../../../../admin-x-ds/global/Heading';
|
||||||
import Hint from '../../../../admin-x-ds/global/Hint';
|
import Hint from '../../../../admin-x-ds/global/Hint';
|
||||||
import ImageUpload from '../../../../admin-x-ds/global/form/ImageUpload';
|
import ImageUpload from '../../../../admin-x-ds/global/form/ImageUpload';
|
||||||
import React, {useContext} from 'react';
|
import React from 'react';
|
||||||
import Select from '../../../../admin-x-ds/global/form/Select';
|
import Select from '../../../../admin-x-ds/global/form/Select';
|
||||||
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
|
import SettingGroupContent from '../../../../admin-x-ds/settings/SettingGroupContent';
|
||||||
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
import TextField from '../../../../admin-x-ds/global/form/TextField';
|
||||||
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
import Toggle from '../../../../admin-x-ds/global/form/Toggle';
|
||||||
import {CustomThemeSetting} from '../../../../types/api';
|
import { CustomThemeSetting } from '../../../../types/api';
|
||||||
import {ServicesContext} from '../../../providers/ServiceProvider';
|
import { getImageUrl, useUploadImage } from '../../../../utils/api/images';
|
||||||
import {humanizeSettingKey} from '../../../../utils/helpers';
|
import { humanizeSettingKey } from '../../../../utils/helpers';
|
||||||
|
|
||||||
const ThemeSetting: React.FC<{
|
const ThemeSetting: React.FC<{
|
||||||
setting: CustomThemeSetting,
|
setting: CustomThemeSetting,
|
||||||
setSetting: <Setting extends CustomThemeSetting>(value: Setting['value']) => void
|
setSetting: <Setting extends CustomThemeSetting>(value: Setting['value']) => void
|
||||||
}> = ({setting, setSetting}) => {
|
}> = ({setting, setSetting}) => {
|
||||||
const {fileService} = useContext(ServicesContext);
|
const {mutateAsync: uploadImage} = useUploadImage();
|
||||||
|
|
||||||
const handleImageUpload = async (file: File) => {
|
const handleImageUpload = async (file: File) => {
|
||||||
const imageUrl = await fileService!.uploadImage(file);
|
const imageUrl = getImageUrl(await uploadImage({file}));
|
||||||
setSetting(imageUrl);
|
setSetting(imageUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -78,7 +78,7 @@ const ThemeSetting: React.FC<{
|
|||||||
const ThemeSettings: React.FC<{ settings: CustomThemeSetting[], updateSetting: (setting: CustomThemeSetting) => void }> = ({settings, updateSetting}) => {
|
const ThemeSettings: React.FC<{ settings: CustomThemeSetting[], updateSetting: (setting: CustomThemeSetting) => void }> = ({settings, updateSetting}) => {
|
||||||
return (
|
return (
|
||||||
<SettingGroupContent className='mt-7'>
|
<SettingGroupContent className='mt-7'>
|
||||||
{settings.map(setting => <ThemeSetting key={setting.key} setSetting={(value: any) => updateSetting({...setting, value})} setting={setting} />)}
|
{settings.map(setting => <ThemeSetting key={setting.key} setSetting={(value) => updateSetting({...setting, value} as CustomThemeSetting)} setting={setting} />)}
|
||||||
</SettingGroupContent>
|
</SettingGroupContent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -9,17 +9,14 @@ import React from 'react';
|
|||||||
import {Theme} from '../../../../types/api';
|
import {Theme} from '../../../../types/api';
|
||||||
import {downloadFile, getGhostPaths} from '../../../../utils/helpers';
|
import {downloadFile, getGhostPaths} from '../../../../utils/helpers';
|
||||||
import {isActiveTheme, isDefaultTheme, isDeletableTheme} from '../../../../models/themes';
|
import {isActiveTheme, isDefaultTheme, isDeletableTheme} from '../../../../models/themes';
|
||||||
import {useApi} from '../../../providers/ServiceProvider';
|
import {useActivateTheme, useDeleteTheme} from '../../../../utils/api/themes';
|
||||||
|
|
||||||
interface ThemeActionProps {
|
interface ThemeActionProps {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
themes: Theme[];
|
|
||||||
updateThemes: (themes: Theme[]) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ThemeSettingProps {
|
interface ThemeSettingProps {
|
||||||
themes: Theme[];
|
themes: Theme[];
|
||||||
setThemes: (themes: Theme[]) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getThemeLabel(theme: Theme): React.ReactNode {
|
function getThemeLabel(theme: Theme): React.ReactNode {
|
||||||
@ -49,26 +46,13 @@ function getThemeVersion(theme: Theme): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ThemeActions: React.FC<ThemeActionProps> = ({
|
const ThemeActions: React.FC<ThemeActionProps> = ({
|
||||||
theme,
|
theme
|
||||||
themes,
|
|
||||||
updateThemes
|
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi();
|
const {mutateAsync: activateTheme} = useActivateTheme();
|
||||||
|
const {mutateAsync: deleteTheme} = useDeleteTheme();
|
||||||
|
|
||||||
const handleActivate = async () => {
|
const handleActivate = async () => {
|
||||||
const data = await api.themes.activate(theme.name);
|
await activateTheme(theme.name);
|
||||||
const updatedTheme = data.themes[0];
|
|
||||||
|
|
||||||
const updatedThemes: Theme[] = themes.map((t) => {
|
|
||||||
if (t.name === updatedTheme.name) {
|
|
||||||
return updatedTheme;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...t,
|
|
||||||
active: false
|
|
||||||
};
|
|
||||||
});
|
|
||||||
updateThemes(updatedThemes);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async () => {
|
const handleDownload = async () => {
|
||||||
@ -98,9 +82,7 @@ const ThemeActions: React.FC<ThemeActionProps> = ({
|
|||||||
okRunningLabel: 'Deleting',
|
okRunningLabel: 'Deleting',
|
||||||
okColor: 'red',
|
okColor: 'red',
|
||||||
onOk: async (modal) => {
|
onOk: async (modal) => {
|
||||||
await api.themes.delete(theme.name);
|
await deleteTheme(theme.name);
|
||||||
const updatedThemes = themes.filter(t => t.name !== theme.name);
|
|
||||||
updateThemes(updatedThemes);
|
|
||||||
modal?.remove();
|
modal?.remove();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -150,8 +132,7 @@ const ThemeActions: React.FC<ThemeActionProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ThemeList:React.FC<ThemeSettingProps> = ({
|
const ThemeList:React.FC<ThemeSettingProps> = ({
|
||||||
themes,
|
themes
|
||||||
setThemes
|
|
||||||
}) => {
|
}) => {
|
||||||
themes.sort((a, b) => {
|
themes.sort((a, b) => {
|
||||||
if (a.active && !b.active) {
|
if (a.active && !b.active) {
|
||||||
@ -173,13 +154,7 @@ const ThemeList:React.FC<ThemeSettingProps> = ({
|
|||||||
return (
|
return (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={theme.name}
|
key={theme.name}
|
||||||
action={
|
action={<ThemeActions theme={theme} />}
|
||||||
<ThemeActions
|
|
||||||
theme={theme}
|
|
||||||
themes={themes}
|
|
||||||
updateThemes={setThemes}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
detail={detail}
|
detail={detail}
|
||||||
id={`theme-${theme.name}`}
|
id={`theme-${theme.name}`}
|
||||||
separator={false}
|
separator={false}
|
||||||
@ -192,16 +167,10 @@ const ThemeList:React.FC<ThemeSettingProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AdvancedThemeSettings: React.FC<ThemeSettingProps> = ({
|
const AdvancedThemeSettings: React.FC<ThemeSettingProps> = ({themes}) => {
|
||||||
themes,
|
|
||||||
setThemes
|
|
||||||
}) => {
|
|
||||||
return (
|
return (
|
||||||
<ModalPage>
|
<ModalPage>
|
||||||
<ThemeList
|
<ThemeList themes={themes} />
|
||||||
setThemes={setThemes}
|
|
||||||
themes={themes}
|
|
||||||
/>
|
|
||||||
</ModalPage>
|
</ModalPage>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -5,9 +5,9 @@ import ListItem from '../../../../admin-x-ds/global/ListItem';
|
|||||||
import NiceModal from '@ebay/nice-modal-react';
|
import NiceModal from '@ebay/nice-modal-react';
|
||||||
import React, {ReactNode, useState} from 'react';
|
import React, {ReactNode, useState} from 'react';
|
||||||
import {ConfirmationModalContent} from '../../../../admin-x-ds/global/modal/ConfirmationModal';
|
import {ConfirmationModalContent} from '../../../../admin-x-ds/global/modal/ConfirmationModal';
|
||||||
import {InstalledTheme, Theme, ThemeProblem} from '../../../../types/api';
|
import {InstalledTheme, ThemeProblem} from '../../../../types/api';
|
||||||
import {showToast} from '../../../../admin-x-ds/global/Toast';
|
import {showToast} from '../../../../admin-x-ds/global/Toast';
|
||||||
import {useApi} from '../../../providers/ServiceProvider';
|
import {useActivateTheme} from '../../../../utils/api/themes';
|
||||||
|
|
||||||
const ThemeProblemView = ({problem}:{problem: ThemeProblem}) => {
|
const ThemeProblemView = ({problem}:{problem: ThemeProblem}) => {
|
||||||
const [isExpanded, setExpanded] = useState(false);
|
const [isExpanded, setExpanded] = useState(false);
|
||||||
@ -44,10 +44,9 @@ const ThemeInstalledModal: React.FC<{
|
|||||||
title: string
|
title: string
|
||||||
prompt: ReactNode
|
prompt: ReactNode
|
||||||
installedTheme: InstalledTheme;
|
installedTheme: InstalledTheme;
|
||||||
setThemes: (callback: (themes: Theme[]) => Theme[]) => void;
|
|
||||||
onActivate?: () => void;
|
onActivate?: () => void;
|
||||||
}> = ({title, prompt, installedTheme, setThemes, onActivate}) => {
|
}> = ({title, prompt, installedTheme, onActivate}) => {
|
||||||
const api = useApi();
|
const {mutateAsync: activateTheme} = useActivateTheme();
|
||||||
|
|
||||||
let errorPrompt = null;
|
let errorPrompt = null;
|
||||||
if (installedTheme.errors) {
|
if (installedTheme.errors) {
|
||||||
@ -87,22 +86,9 @@ const ThemeInstalledModal: React.FC<{
|
|||||||
title={title}
|
title={title}
|
||||||
onOk={async (activateModal) => {
|
onOk={async (activateModal) => {
|
||||||
if (!installedTheme.active) {
|
if (!installedTheme.active) {
|
||||||
const resData = await api.themes.activate(installedTheme.name);
|
const resData = await activateTheme(installedTheme.name);
|
||||||
const updatedTheme = resData.themes[0];
|
const updatedTheme = resData.themes[0];
|
||||||
|
|
||||||
setThemes((_themes) => {
|
|
||||||
const updatedThemes: Theme[] = _themes.map((t) => {
|
|
||||||
if (t.name === updatedTheme.name) {
|
|
||||||
return updatedTheme;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...t,
|
|
||||||
active: false
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return updatedThemes;
|
|
||||||
});
|
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
message: <div><span className='capitalize'>{updatedTheme.name}</span> is now your active theme.</div>
|
message: <div><span className='capitalize'>{updatedTheme.name}</span> is now your active theme.</div>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import {useCallback, useEffect, useState} from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
export type Dirtyable<Data> = Data & {
|
export type Dirtyable<Data> = Data & {
|
||||||
dirty?: boolean;
|
dirty?: boolean;
|
||||||
@ -23,7 +23,7 @@ export interface FormHook<State> {
|
|||||||
|
|
||||||
// TODO: figure out if we need to extend `any`?
|
// TODO: figure out if we need to extend `any`?
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint
|
||||||
const useForm = <State extends any>({initialState, onSave}: {
|
const useForm = <State>({initialState, onSave}: {
|
||||||
initialState: State,
|
initialState: State,
|
||||||
onSave: () => void | Promise<void>
|
onSave: () => void | Promise<void>
|
||||||
}): FormHook<State> => {
|
}): FormHook<State> => {
|
@ -1,37 +0,0 @@
|
|||||||
import {useCallback, useEffect, useState} from 'react';
|
|
||||||
|
|
||||||
type FetcherFunction<T> = () => Promise<T>;
|
|
||||||
|
|
||||||
export function useRequest<T>(fetcher: FetcherFunction<T>) {
|
|
||||||
const [data, setData] = useState<T | null>(null);
|
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const responseData = await fetcher();
|
|
||||||
setData(responseData);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.message);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [fetcher]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchData();
|
|
||||||
}, [fetchData]);
|
|
||||||
|
|
||||||
const refetch = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
await fetchData();
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
data,
|
|
||||||
error,
|
|
||||||
isLoading,
|
|
||||||
refetch
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,29 +0,0 @@
|
|||||||
import {RolesContext} from '../components/providers/RolesProvider';
|
|
||||||
import {UserRole} from '../types/api';
|
|
||||||
import {useContext} from 'react';
|
|
||||||
|
|
||||||
export type RolesHook = {
|
|
||||||
roles: UserRole[];
|
|
||||||
assignableRoles: UserRole[];
|
|
||||||
getRoleId: (roleName: string, roles: UserRole[]) => string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function getRoleId(roleName: string, roles: UserRole[]): string {
|
|
||||||
const role = roles.find((r) => {
|
|
||||||
return r.name.toLowerCase() === roleName?.toLowerCase();
|
|
||||||
});
|
|
||||||
|
|
||||||
return role?.id || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const useRoles = (): RolesHook => {
|
|
||||||
const {roles, assignableRoles} = useContext(RolesContext);
|
|
||||||
|
|
||||||
return {
|
|
||||||
roles,
|
|
||||||
assignableRoles,
|
|
||||||
getRoleId
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useRoles;
|
|
@ -1,8 +1,8 @@
|
|||||||
import React, {useContext, useEffect, useRef, useState} from 'react';
|
import React, {useEffect, useRef, useState} from 'react';
|
||||||
import useForm, {SaveState} from './useForm';
|
import useForm, {SaveState} from './useForm';
|
||||||
import useGlobalDirtyState from './useGlobalDirtyState';
|
import useGlobalDirtyState from './useGlobalDirtyState';
|
||||||
|
import useSettings from './useSettings';
|
||||||
import {Setting, SettingValue, SiteData} from '../types/api';
|
import {Setting, SettingValue, SiteData} from '../types/api';
|
||||||
import {SettingsContext} from '../components/providers/SettingsProvider';
|
|
||||||
|
|
||||||
interface LocalSetting extends Setting {
|
interface LocalSetting extends Setting {
|
||||||
dirty?: boolean;
|
dirty?: boolean;
|
||||||
@ -24,8 +24,7 @@ const useSettingGroup = (): SettingGroupHook => {
|
|||||||
// create a ref to focus the input field
|
// create a ref to focus the input field
|
||||||
const focusRef = useRef<HTMLInputElement>(null);
|
const focusRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// get the settings and saveSettings function from the Settings Context
|
const {siteData, settings, saveSettings} = useSettings();
|
||||||
const {siteData, settings, saveSettings} = useContext(SettingsContext) || {};
|
|
||||||
|
|
||||||
const [isEditing, setEditing] = useState(false);
|
const [isEditing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
96
apps/admin-x-settings/src/hooks/useSettings.tsx
Normal file
96
apps/admin-x-settings/src/hooks/useSettings.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import {Setting} from '../types/api';
|
||||||
|
import {useCallback, useMemo} from 'react';
|
||||||
|
import {useEditSettings} from '../utils/api/settings';
|
||||||
|
import {useGlobalData} from '../components/providers/DataProvider';
|
||||||
|
|
||||||
|
function serialiseSettingsData(settings: Setting[]): Setting[] {
|
||||||
|
return settings.map((setting) => {
|
||||||
|
if (setting.key === 'facebook' && setting.value) {
|
||||||
|
const value = setting.value as string;
|
||||||
|
let [, user] = value.match(/(\S+)/) || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: setting.key,
|
||||||
|
value: `https://www.facebook.com/${user}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (setting.key === 'twitter' && setting.value) {
|
||||||
|
const value = setting.value as string;
|
||||||
|
let [, user] = value.match(/@?([^/]*)/) || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: setting.key,
|
||||||
|
value: `https://twitter.com/${user}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: setting.key,
|
||||||
|
value: setting.value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deserializeSettings(settings: Setting[]): Setting[] {
|
||||||
|
return settings.map((setting) => {
|
||||||
|
if (setting.key === 'facebook' && setting.value) {
|
||||||
|
const deserialized = setting.value as string;
|
||||||
|
let [, user] = deserialized.match(/(?:https:\/\/)(?:www\.)(?:facebook\.com)\/(?:#!\/)?(\w+\/?\S+)/mi) || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: setting.key,
|
||||||
|
value: user
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting.key === 'twitter' && setting.value) {
|
||||||
|
const deserialized = setting.value as string;
|
||||||
|
let [, user] = deserialized.match(/(?:https:\/\/)(?:twitter\.com)\/(?:#!\/)?@?([^/]*)/) || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: setting.key,
|
||||||
|
value: `@${user}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: setting.key,
|
||||||
|
value: setting.value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Make this not a provider
|
||||||
|
const useSettings = () => {
|
||||||
|
const {settings, siteData, config} = useGlobalData();
|
||||||
|
const {mutateAsync: editSettings} = useEditSettings();
|
||||||
|
|
||||||
|
const saveSettings = useCallback(async (updatedSettings: Setting[]) => {
|
||||||
|
try {
|
||||||
|
// handle transformation for settings before save
|
||||||
|
updatedSettings = deserializeSettings(updatedSettings);
|
||||||
|
// Make an API call to save the updated settings
|
||||||
|
const data = await editSettings(updatedSettings);
|
||||||
|
const newSettings = serialiseSettingsData(data.settings);
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings: newSettings,
|
||||||
|
meta: data.meta
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Log error in settings API
|
||||||
|
return {settings: []};
|
||||||
|
}
|
||||||
|
}, [editSettings]);
|
||||||
|
|
||||||
|
const serializedSettings = useMemo(() => serialiseSettingsData(settings), [settings]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings: serializedSettings,
|
||||||
|
saveSettings,
|
||||||
|
siteData,
|
||||||
|
config
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSettings;
|
@ -1,8 +1,7 @@
|
|||||||
import React, {useContext} from 'react';
|
|
||||||
import {RolesContext} from '../components/providers/RolesProvider';
|
|
||||||
import {User} from '../types/api';
|
import {User} from '../types/api';
|
||||||
import {UserInvite} from '../utils/api';
|
import {UserInvite} from '../utils/api/invites';
|
||||||
import {UsersContext} from '../components/providers/UsersProvider';
|
import {useBrowseRoles} from '../utils/api/roles';
|
||||||
|
import {useGlobalData} from '../components/providers/DataProvider';
|
||||||
|
|
||||||
export type UsersHook = {
|
export type UsersHook = {
|
||||||
users: User[];
|
users: User[];
|
||||||
@ -13,9 +12,6 @@ export type UsersHook = {
|
|||||||
authorUsers: User[];
|
authorUsers: User[];
|
||||||
contributorUsers: User[];
|
contributorUsers: User[];
|
||||||
currentUser: User|null;
|
currentUser: User|null;
|
||||||
updateUser?: (user: User) => Promise<void>;
|
|
||||||
setInvites: (invites: UserInvite[]) => void;
|
|
||||||
setUsers: React.Dispatch<React.SetStateAction<User[]>>
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function getUsersByRole(users: User[], role: string): User[] {
|
function getUsersByRole(users: User[], role: string): User[] {
|
||||||
@ -31,15 +27,16 @@ function getOwnerUser(users: User[]): User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const useStaffUsers = (): UsersHook => {
|
const useStaffUsers = (): UsersHook => {
|
||||||
const {users, currentUser, updateUser, invites, setInvites, setUsers} = useContext(UsersContext);
|
const {users, currentUser, invites} = useGlobalData();
|
||||||
const {roles} = useContext(RolesContext);
|
const {data: {roles} = {}} = useBrowseRoles();
|
||||||
|
|
||||||
const ownerUser = getOwnerUser(users);
|
const ownerUser = getOwnerUser(users);
|
||||||
const adminUsers = getUsersByRole(users, 'Administrator');
|
const adminUsers = getUsersByRole(users, 'Administrator');
|
||||||
const editorUsers = getUsersByRole(users, 'Editor');
|
const editorUsers = getUsersByRole(users, 'Editor');
|
||||||
const authorUsers = getUsersByRole(users, 'Author');
|
const authorUsers = getUsersByRole(users, 'Author');
|
||||||
const contributorUsers = getUsersByRole(users, 'Contributor');
|
const contributorUsers = getUsersByRole(users, 'Contributor');
|
||||||
const mappedInvites = invites?.map((invite) => {
|
const mappedInvites = invites?.map((invite) => {
|
||||||
let role = roles.find((r) => {
|
let role = roles?.find((r) => {
|
||||||
return invite.role_id === r.id;
|
return invite.role_id === r.id;
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
@ -56,10 +53,7 @@ const useStaffUsers = (): UsersHook => {
|
|||||||
authorUsers,
|
authorUsers,
|
||||||
contributorUsers,
|
contributorUsers,
|
||||||
currentUser,
|
currentUser,
|
||||||
invites: mappedInvites,
|
invites: mappedInvites
|
||||||
updateUser,
|
|
||||||
setInvites,
|
|
||||||
setUsers
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
import {Theme} from '../types/api';
|
|
||||||
import {ThemesResponseType} from '../utils/api';
|
|
||||||
import {useApi} from '../components/providers/ServiceProvider';
|
|
||||||
import {useEffect, useState} from 'react';
|
|
||||||
import {useRequest} from './useRequest';
|
|
||||||
|
|
||||||
export function useThemes() {
|
|
||||||
const api = useApi();
|
|
||||||
const [themes, setThemes] = useState<Theme[]>([]);
|
|
||||||
|
|
||||||
const {data, error, isLoading} = useRequest<ThemesResponseType>(api.themes.browse);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (data) {
|
|
||||||
setThemes(data.themes);
|
|
||||||
}
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
themes,
|
|
||||||
error,
|
|
||||||
isLoading,
|
|
||||||
setThemes
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,3 +1,7 @@
|
|||||||
|
export type JSONValue = string|number|boolean|null|Date|JSONObject|JSONArray;
|
||||||
|
export interface JSONObject { [key: string]: JSONValue }
|
||||||
|
export interface JSONArray extends Array<string|number|boolean|Date|JSONObject|JSONValue> {}
|
||||||
|
|
||||||
export type SettingValue = string | boolean | null;
|
export type SettingValue = string | boolean | null;
|
||||||
|
|
||||||
export type Setting = {
|
export type Setting = {
|
||||||
@ -5,9 +9,7 @@ export type Setting = {
|
|||||||
value: SettingValue;
|
value: SettingValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Config = {
|
export type Config = JSONObject;
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -1,482 +0,0 @@
|
|||||||
import {Config, CustomThemeSetting, InstalledTheme, Label, Offer, Post, Setting, SiteData, Theme, Tier, User, UserRole} from '../types/api';
|
|
||||||
import {getGhostPaths} from './helpers';
|
|
||||||
|
|
||||||
export interface Meta {
|
|
||||||
pagination: {
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
pages: number;
|
|
||||||
total: number;
|
|
||||||
next: number;
|
|
||||||
prev: number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SettingsResponseMeta = Meta & { sent_email_verification?: boolean }
|
|
||||||
|
|
||||||
export interface SettingsResponseType {
|
|
||||||
meta?: SettingsResponseMeta;
|
|
||||||
settings: Setting[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConfigResponseType {
|
|
||||||
config: Config;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsersResponseType {
|
|
||||||
meta?: Meta;
|
|
||||||
users: User[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteUserResponse {
|
|
||||||
meta: {
|
|
||||||
filename: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RolesResponseType {
|
|
||||||
meta?: Meta;
|
|
||||||
roles: UserRole[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserInvite {
|
|
||||||
created_at: string;
|
|
||||||
email: string;
|
|
||||||
expires: number;
|
|
||||||
id: string;
|
|
||||||
role_id: string;
|
|
||||||
role?: string;
|
|
||||||
status: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InvitesResponseType {
|
|
||||||
meta?: Meta;
|
|
||||||
invites: UserInvite[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CustomThemeSettingsResponseType {
|
|
||||||
custom_theme_settings: CustomThemeSetting[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PostsResponseType {
|
|
||||||
meta?: Meta
|
|
||||||
posts: Post[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TiersResponseType {
|
|
||||||
meta?: Meta
|
|
||||||
tiers: Tier[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LabelsResponseType {
|
|
||||||
meta?: Meta
|
|
||||||
labels: Label[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OffersResponseType {
|
|
||||||
meta?: Meta
|
|
||||||
offers: Offer[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SiteResponseType {
|
|
||||||
site: SiteData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImagesResponseType {
|
|
||||||
images: {
|
|
||||||
url: string;
|
|
||||||
ref: string | null;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PasswordUpdateResponseType {
|
|
||||||
password: [{
|
|
||||||
message: string;
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ThemesResponseType {
|
|
||||||
themes: Theme[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ThemesInstallResponseType {
|
|
||||||
themes: InstalledTheme[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RequestOptions {
|
|
||||||
method?: string;
|
|
||||||
body?: string | FormData;
|
|
||||||
headers?: {
|
|
||||||
'Content-Type'?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BrowseRoleOptions {
|
|
||||||
queryParams: {
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UpdatePasswordOptions {
|
|
||||||
newPassword: string;
|
|
||||||
confirmNewPassword: string;
|
|
||||||
userId: string;
|
|
||||||
oldPassword?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface API {
|
|
||||||
settings: {
|
|
||||||
browse: () => Promise<SettingsResponseType>;
|
|
||||||
edit: (newSettings: Setting[]) => Promise<SettingsResponseType>;
|
|
||||||
};
|
|
||||||
config: {
|
|
||||||
browse: () => Promise<ConfigResponseType>;
|
|
||||||
};
|
|
||||||
users: {
|
|
||||||
browse: () => Promise<UsersResponseType>;
|
|
||||||
currentUser: () => Promise<User>;
|
|
||||||
edit: (editedUser: User) => Promise<UsersResponseType>;
|
|
||||||
delete: (userId: string) => Promise<DeleteUserResponse>;
|
|
||||||
updatePassword: (options: UpdatePasswordOptions) => Promise<PasswordUpdateResponseType>;
|
|
||||||
makeOwner: (userId: string) => Promise<UsersResponseType>;
|
|
||||||
};
|
|
||||||
roles: {
|
|
||||||
browse: (options?: BrowseRoleOptions) => Promise<RolesResponseType>;
|
|
||||||
};
|
|
||||||
site: {
|
|
||||||
browse: () => Promise<SiteResponseType>;
|
|
||||||
};
|
|
||||||
images: {
|
|
||||||
upload: ({file}: {file: File}) => Promise<ImagesResponseType>;
|
|
||||||
};
|
|
||||||
invites: {
|
|
||||||
browse: () => Promise<InvitesResponseType>;
|
|
||||||
add: ({email, roleId} : {
|
|
||||||
email: string;
|
|
||||||
roleId: string;
|
|
||||||
expires?: number;
|
|
||||||
status?: string;
|
|
||||||
token?: string;
|
|
||||||
}) => Promise<InvitesResponseType>;
|
|
||||||
delete: (inviteId: string) => Promise<void>;
|
|
||||||
};
|
|
||||||
customThemeSettings: {
|
|
||||||
browse: () => Promise<CustomThemeSettingsResponseType>
|
|
||||||
edit: (newSettings: CustomThemeSetting[]) => Promise<CustomThemeSettingsResponseType>
|
|
||||||
};
|
|
||||||
latestPost: {
|
|
||||||
browse: () => Promise<PostsResponseType>
|
|
||||||
};
|
|
||||||
tiers: {
|
|
||||||
browse: () => Promise<TiersResponseType>
|
|
||||||
edit: (newTier: Tier) => Promise<TiersResponseType>
|
|
||||||
add: (newTier: Partial<Tier>) => Promise<TiersResponseType>
|
|
||||||
};
|
|
||||||
labels: {
|
|
||||||
browse: () => Promise<LabelsResponseType>
|
|
||||||
};
|
|
||||||
offers: {
|
|
||||||
browse: () => Promise<OffersResponseType>
|
|
||||||
};
|
|
||||||
themes: {
|
|
||||||
browse: () => Promise<ThemesResponseType>;
|
|
||||||
activate: (themeName: string) => Promise<ThemesResponseType>;
|
|
||||||
delete: (themeName: string) => Promise<void>;
|
|
||||||
install: (repo: string) => Promise<ThemesInstallResponseType>;
|
|
||||||
upload: ({file}: {file: File}) => Promise<ThemesInstallResponseType>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GhostApiOptions {
|
|
||||||
ghostVersion: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupGhostApi({ghostVersion}: GhostApiOptions): API {
|
|
||||||
const {apiRoot} = getGhostPaths();
|
|
||||||
|
|
||||||
function fetcher(url: string, options: RequestOptions = {}) {
|
|
||||||
const endpoint = `${apiRoot}${url}`;
|
|
||||||
// By default, we set the Content-Type header to application/json
|
|
||||||
const defaultHeaders = {
|
|
||||||
'app-pragma': 'no-cache',
|
|
||||||
'x-ghost-version': ghostVersion
|
|
||||||
};
|
|
||||||
const headers = options?.headers || {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
};
|
|
||||||
return fetch(endpoint, {
|
|
||||||
headers: {
|
|
||||||
...defaultHeaders,
|
|
||||||
...headers
|
|
||||||
},
|
|
||||||
method: 'GET',
|
|
||||||
mode: 'cors',
|
|
||||||
credentials: 'include',
|
|
||||||
...options
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const api: API = {
|
|
||||||
settings: {
|
|
||||||
browse: async () => {
|
|
||||||
const queryString = `group=site,theme,private,members,portal,newsletter,email,amp,labs,slack,unsplash,views,firstpromoter,editor,comments,analytics,announcement,pintura`;
|
|
||||||
|
|
||||||
const response = await fetcher(`/settings/?${queryString}`, {});
|
|
||||||
|
|
||||||
const data: SettingsResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
edit: async (newSettings: Setting[]) => {
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
settings: newSettings
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetcher(`/settings/`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: payload
|
|
||||||
});
|
|
||||||
|
|
||||||
const data: SettingsResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
browse: async () => {
|
|
||||||
const response = await fetcher(`/config/`, {});
|
|
||||||
const data: ConfigResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
users: {
|
|
||||||
browse: async () => {
|
|
||||||
const response = await fetcher(`/users/?limit=all&include=roles`, {});
|
|
||||||
const data: UsersResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
currentUser: async (): Promise<User> => {
|
|
||||||
const response = await fetcher(`/users/me/`, {});
|
|
||||||
const data: UsersResponseType = await response.json();
|
|
||||||
return data.users[0];
|
|
||||||
},
|
|
||||||
edit: async (editedUser: User) => {
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
users: [editedUser]
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetcher(`/users/${editedUser.id}/?include=roles`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: payload
|
|
||||||
});
|
|
||||||
|
|
||||||
const data: UsersResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
updatePassword: async ({newPassword, confirmNewPassword, userId, oldPassword}) => {
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
password: [{
|
|
||||||
user_id: userId,
|
|
||||||
oldPassword: oldPassword || '',
|
|
||||||
newPassword: newPassword,
|
|
||||||
ne2Password: confirmNewPassword
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
const response = await fetcher(`/users/password/`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: payload
|
|
||||||
});
|
|
||||||
const data: PasswordUpdateResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
delete: async (userId: string) => {
|
|
||||||
const response = await fetcher(`/users/${userId}/`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
const data: DeleteUserResponse = await response.json();
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
makeOwner: async (userId: string) => {
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
owner: [{
|
|
||||||
id: userId
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
const response = await fetcher(`/users/owner/`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: payload
|
|
||||||
});
|
|
||||||
const data: UsersResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
roles: {
|
|
||||||
browse: async (options?: BrowseRoleOptions) => {
|
|
||||||
const queryParams = options?.queryParams || {};
|
|
||||||
queryParams.limit = 'all';
|
|
||||||
const queryString = Object.keys(options?.queryParams || {})
|
|
||||||
.map(key => `${key}=${options?.queryParams[key]}`)
|
|
||||||
.join('&');
|
|
||||||
|
|
||||||
const response = await fetcher(`/roles/?${queryString}`, {});
|
|
||||||
const data: RolesResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
site: {
|
|
||||||
browse: async () => {
|
|
||||||
const response = await fetcher(`/site/`, {});
|
|
||||||
const data: any = await response.json();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
images: {
|
|
||||||
upload: async ({file}: {file: File}) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
formData.append('purpose', 'image');
|
|
||||||
|
|
||||||
const response = await fetcher(`/images/upload/`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
headers: {}
|
|
||||||
});
|
|
||||||
const data: any = await response.json();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
invites: {
|
|
||||||
browse: async () => {
|
|
||||||
const response = await fetcher(`/invites/`, {});
|
|
||||||
const data: InvitesResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
add: async ({email, roleId}) => {
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
invites: [{
|
|
||||||
email: email,
|
|
||||||
role_id: roleId,
|
|
||||||
expires: null,
|
|
||||||
status: null,
|
|
||||||
token: null
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
const response = await fetcher(`/invites/`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: payload
|
|
||||||
});
|
|
||||||
const data: InvitesResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
delete: async (inviteId: string) => {
|
|
||||||
await fetcher(`/invites/${inviteId}/`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
customThemeSettings: {
|
|
||||||
browse: async () => {
|
|
||||||
const response = await fetcher('/custom_theme_settings/');
|
|
||||||
|
|
||||||
const data: CustomThemeSettingsResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
edit: async (newSettings) => {
|
|
||||||
const response = await fetcher('/custom_theme_settings/', {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({custom_theme_settings: newSettings})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data: CustomThemeSettingsResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
latestPost: {
|
|
||||||
browse: async () => {
|
|
||||||
const response = await fetcher('/posts/?filter=status%3Apublished&order=published_at%20DESC&limit=1&fields=id,url');
|
|
||||||
const data: PostsResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tiers: {
|
|
||||||
browse: async () => {
|
|
||||||
const response = await fetcher(`/tiers/?limit=all`);
|
|
||||||
const data: TiersResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
edit: async (tier) => {
|
|
||||||
const response = await fetcher(`/tiers/${tier.id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({tiers: [tier]})
|
|
||||||
});
|
|
||||||
const data: TiersResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
add: async (tier) => {
|
|
||||||
const response = await fetcher(`/tiers/`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({tiers: [tier]})
|
|
||||||
});
|
|
||||||
const data: TiersResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
labels: {
|
|
||||||
browse: async () => {
|
|
||||||
const response = await fetcher('/labels/?limit=all');
|
|
||||||
const data: LabelsResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
offers: {
|
|
||||||
browse: async () => {
|
|
||||||
const response = await fetcher('/offers/?limit=all');
|
|
||||||
const data: OffersResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
themes: {
|
|
||||||
browse: async () => {
|
|
||||||
const response = await fetcher('/themes/');
|
|
||||||
const data: ThemesResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
activate: async (themeName: string) => {
|
|
||||||
const response = await fetcher(`/themes/${themeName}/activate/`, {
|
|
||||||
method: 'PUT'
|
|
||||||
});
|
|
||||||
const data: ThemesResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
delete: async (themeName: string) => {
|
|
||||||
await fetcher(`/themes/${themeName}/`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
},
|
|
||||||
install: async (repo) => {
|
|
||||||
const response = await fetcher(`/themes/install/?source=github&ref=${encodeURIComponent(repo)}`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
const data: ThemesResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
upload: async ({file}: {file: File}) => {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const response = await fetcher(`/themes/upload/`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData,
|
|
||||||
headers: {}
|
|
||||||
});
|
|
||||||
const data: ThemesInstallResponseType = await response.json();
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return api;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default setupGhostApi;
|
|
13
apps/admin-x-settings/src/utils/api/config.ts
Normal file
13
apps/admin-x-settings/src/utils/api/config.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import {Config} from '../../types/api';
|
||||||
|
import {createQuery} from '../apiRequests';
|
||||||
|
|
||||||
|
export interface ConfigResponseType {
|
||||||
|
config: Config;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataType = 'ConfigResponseType';
|
||||||
|
|
||||||
|
export const useBrowseConfig = createQuery<ConfigResponseType>({
|
||||||
|
dataType,
|
||||||
|
path: '/config/'
|
||||||
|
});
|
24
apps/admin-x-settings/src/utils/api/customThemeSettings.ts
Normal file
24
apps/admin-x-settings/src/utils/api/customThemeSettings.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { CustomThemeSetting, Setting } from '../../types/api';
|
||||||
|
import { createMutation, createQuery } from '../apiRequests';
|
||||||
|
|
||||||
|
export interface CustomThemeSettingsResponseType {
|
||||||
|
custom_theme_settings: CustomThemeSetting[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataType = 'CustomThemeSettingsResponseType';
|
||||||
|
|
||||||
|
export const useBrowseCustomThemeSettings = createQuery<CustomThemeSettingsResponseType>({
|
||||||
|
dataType,
|
||||||
|
path: '/custom_theme_settings/'
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useEditCustomThemeSettings = createMutation<CustomThemeSettingsResponseType, Setting[]>({
|
||||||
|
method: 'PUT',
|
||||||
|
path: () => '/custom_theme_settings/',
|
||||||
|
body: settings => ({custom_theme_settings: settings}),
|
||||||
|
|
||||||
|
updateQueries: {
|
||||||
|
dataType,
|
||||||
|
update: newData => newData
|
||||||
|
}
|
||||||
|
});
|
21
apps/admin-x-settings/src/utils/api/images.ts
Normal file
21
apps/admin-x-settings/src/utils/api/images.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import {createMutation} from '../apiRequests';
|
||||||
|
|
||||||
|
export interface ImagesResponseType {
|
||||||
|
images: {
|
||||||
|
url: string;
|
||||||
|
ref: string | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUploadImage = createMutation<ImagesResponseType, {file: File}>({
|
||||||
|
method: 'POST',
|
||||||
|
path: () => '/images/upload/',
|
||||||
|
body: ({file}) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('purpose', 'image');
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getImageUrl = (response: ImagesResponseType) => response.images[0].url;
|
61
apps/admin-x-settings/src/utils/api/invites.ts
Normal file
61
apps/admin-x-settings/src/utils/api/invites.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { Meta, createMutation, createQuery } from '../apiRequests';
|
||||||
|
|
||||||
|
export interface UserInvite {
|
||||||
|
created_at: string;
|
||||||
|
email: string;
|
||||||
|
expires: number;
|
||||||
|
id: string;
|
||||||
|
role_id: string;
|
||||||
|
role?: string;
|
||||||
|
status: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvitesResponseType {
|
||||||
|
meta?: Meta;
|
||||||
|
invites: UserInvite[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataType = 'InvitesResponseType';
|
||||||
|
|
||||||
|
export const useBrowseInvites = createQuery<InvitesResponseType>({
|
||||||
|
dataType,
|
||||||
|
path: '/invites/'
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useAddInvite = createMutation<InvitesResponseType, {email: string, roleId: string}>({
|
||||||
|
method: 'POST',
|
||||||
|
path: () => '/invites/',
|
||||||
|
body: ({email, roleId}) => ({
|
||||||
|
invites: [{
|
||||||
|
email: email,
|
||||||
|
role_id: roleId,
|
||||||
|
expires: null,
|
||||||
|
status: null,
|
||||||
|
token: null
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
updateQueries: {
|
||||||
|
dataType,
|
||||||
|
// Assume that all invite queries should include this new one
|
||||||
|
update: (newData, currentData) => ({
|
||||||
|
...(currentData as InvitesResponseType),
|
||||||
|
invites: [
|
||||||
|
...((currentData as InvitesResponseType).invites),
|
||||||
|
...newData.invites
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useDeleteInvite = createMutation<unknown, string>({
|
||||||
|
path: id => `/invites/${id}/`,
|
||||||
|
method: 'DELETE',
|
||||||
|
updateQueries: {
|
||||||
|
dataType,
|
||||||
|
update: (_, currentData, id) => ({
|
||||||
|
...(currentData as InvitesResponseType),
|
||||||
|
invites: (currentData as InvitesResponseType).invites.filter(invite => invite.id !== id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
15
apps/admin-x-settings/src/utils/api/labels.ts
Normal file
15
apps/admin-x-settings/src/utils/api/labels.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import {Label} from '../../types/api';
|
||||||
|
import {Meta, createQuery} from '../apiRequests';
|
||||||
|
|
||||||
|
export interface LabelsResponseType {
|
||||||
|
meta?: Meta
|
||||||
|
labels: Label[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataType = 'LabelsResponseType';
|
||||||
|
|
||||||
|
export const useBrowseLabels = createQuery<LabelsResponseType>({
|
||||||
|
dataType,
|
||||||
|
path: '/labels/',
|
||||||
|
defaultSearchParams: {limit: 'all'}
|
||||||
|
});
|
15
apps/admin-x-settings/src/utils/api/offers.ts
Normal file
15
apps/admin-x-settings/src/utils/api/offers.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import {Meta, createQuery} from '../apiRequests';
|
||||||
|
import {Offer} from '../../types/api';
|
||||||
|
|
||||||
|
export interface OffersResponseType {
|
||||||
|
meta?: Meta
|
||||||
|
offers: Offer[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataType = 'OffersResponseType';
|
||||||
|
|
||||||
|
export const useBrowseOffers = createQuery<OffersResponseType>({
|
||||||
|
dataType,
|
||||||
|
path: '/offers/',
|
||||||
|
defaultSearchParams: {limit: 'all'}
|
||||||
|
});
|
14
apps/admin-x-settings/src/utils/api/posts.ts
Normal file
14
apps/admin-x-settings/src/utils/api/posts.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import {Meta, createQuery} from '../apiRequests';
|
||||||
|
import {Post} from '../../types/api';
|
||||||
|
|
||||||
|
export interface PostsResponseType {
|
||||||
|
meta?: Meta
|
||||||
|
posts: Post[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataType = 'PostsResponseType';
|
||||||
|
|
||||||
|
export const useBrowsePosts = createQuery<PostsResponseType>({
|
||||||
|
dataType,
|
||||||
|
path: '/posts/'
|
||||||
|
});
|
15
apps/admin-x-settings/src/utils/api/roles.ts
Normal file
15
apps/admin-x-settings/src/utils/api/roles.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import {Meta, createQuery} from '../apiRequests';
|
||||||
|
import {UserRole} from '../../types/api';
|
||||||
|
|
||||||
|
export interface RolesResponseType {
|
||||||
|
meta?: Meta;
|
||||||
|
roles: UserRole[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataType = 'RolesResponseType';
|
||||||
|
|
||||||
|
export const useBrowseRoles = createQuery<RolesResponseType>({
|
||||||
|
dataType,
|
||||||
|
path: '/roles/',
|
||||||
|
defaultSearchParams: {limit: 'all'}
|
||||||
|
});
|
29
apps/admin-x-settings/src/utils/api/settings.ts
Normal file
29
apps/admin-x-settings/src/utils/api/settings.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import {Meta, createMutation, createQuery} from '../apiRequests';
|
||||||
|
import {Setting} from '../../types/api';
|
||||||
|
|
||||||
|
export type SettingsResponseMeta = Meta & { sent_email_verification?: boolean }
|
||||||
|
|
||||||
|
export interface SettingsResponseType {
|
||||||
|
meta?: SettingsResponseMeta;
|
||||||
|
settings: Setting[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataType = 'SettingsResponseType';
|
||||||
|
|
||||||
|
export const useBrowseSettings = createQuery<SettingsResponseType>({
|
||||||
|
dataType,
|
||||||
|
path: '/settings/',
|
||||||
|
defaultSearchParams: {
|
||||||
|
group: 'site,theme,private,members,portal,newsletter,email,amp,labs,slack,unsplash,views,firstpromoter,editor,comments,analytics,announcement,pintura'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useEditSettings = createMutation<SettingsResponseType, Setting[]>({
|
||||||
|
method: 'PUT',
|
||||||
|
path: () => '/settings/',
|
||||||
|
body: settings => ({settings}),
|
||||||
|
updateQueries: {
|
||||||
|
dataType,
|
||||||
|
update: newData => newData
|
||||||
|
}
|
||||||
|
});
|
13
apps/admin-x-settings/src/utils/api/site.ts
Normal file
13
apps/admin-x-settings/src/utils/api/site.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import {SiteData} from '../../types/api';
|
||||||
|
import {createQuery} from '../apiRequests';
|
||||||
|
|
||||||
|
export interface SiteResponseType {
|
||||||
|
site: SiteData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataType = 'SiteResponseType';
|
||||||
|
|
||||||
|
export const useBrowseSite = createQuery<SiteResponseType>({
|
||||||
|
dataType,
|
||||||
|
path: '/site/'
|
||||||
|
});
|
87
apps/admin-x-settings/src/utils/api/themes.ts
Normal file
87
apps/admin-x-settings/src/utils/api/themes.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { InstalledTheme, Theme } from '../../types/api';
|
||||||
|
import { createMutation, createQuery } from '../apiRequests';
|
||||||
|
|
||||||
|
export interface ThemesResponseType {
|
||||||
|
themes: Theme[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemesInstallResponseType {
|
||||||
|
themes: InstalledTheme[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataType = 'ThemesResponseType';
|
||||||
|
|
||||||
|
export const useBrowseThemes = createQuery<ThemesResponseType>({
|
||||||
|
dataType,
|
||||||
|
path: '/themes/'
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useActivateTheme = createMutation<ThemesResponseType, string>({
|
||||||
|
method: 'PUT',
|
||||||
|
path: name => `/themes/${name}/activate/`,
|
||||||
|
updateQueries: {
|
||||||
|
dataType,
|
||||||
|
update: (newData: ThemesResponseType, currentData: unknown) => ({
|
||||||
|
...(currentData as ThemesResponseType),
|
||||||
|
themes: (currentData as ThemesResponseType).themes.map((theme) => {
|
||||||
|
const newTheme = newData.themes.find(({name}) => name === theme.name);
|
||||||
|
|
||||||
|
if (newTheme) {
|
||||||
|
return newTheme;
|
||||||
|
} else {
|
||||||
|
return {...theme, active: false};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useDeleteTheme = createMutation<unknown, string>({
|
||||||
|
method: 'DELETE',
|
||||||
|
path: name => `/themes/${name}/`,
|
||||||
|
updateQueries: {
|
||||||
|
dataType,
|
||||||
|
update: (_, currentData, name) => ({
|
||||||
|
...(currentData as ThemesResponseType),
|
||||||
|
themes: (currentData as ThemesResponseType).themes.filter(theme => theme.name !== name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useInstallTheme = createMutation<ThemesInstallResponseType, string>({
|
||||||
|
method: 'POST',
|
||||||
|
path: () => '/themes/install/',
|
||||||
|
searchParams: repo => ({source: 'github', ref: repo}),
|
||||||
|
updateQueries: {
|
||||||
|
dataType,
|
||||||
|
// Assume that all invite queries should include this new one
|
||||||
|
update: (newData, currentData) => ({
|
||||||
|
...(currentData as ThemesResponseType),
|
||||||
|
themes: [
|
||||||
|
...((currentData as ThemesResponseType).themes),
|
||||||
|
...newData.themes
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useUploadTheme = createMutation<ThemesInstallResponseType, {file: File}>({
|
||||||
|
method: 'POST',
|
||||||
|
path: () => '/themes/upload/',
|
||||||
|
body: ({file}) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
return formData;
|
||||||
|
},
|
||||||
|
updateQueries: {
|
||||||
|
dataType,
|
||||||
|
// Assume that all invite queries should include this new one
|
||||||
|
update: (newData, currentData) => ({
|
||||||
|
...(currentData as ThemesResponseType),
|
||||||
|
themes: [
|
||||||
|
...((currentData as ThemesResponseType).themes),
|
||||||
|
...newData.themes
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
41
apps/admin-x-settings/src/utils/api/tiers.ts
Normal file
41
apps/admin-x-settings/src/utils/api/tiers.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Meta, createMutation, createQuery } from '../apiRequests';
|
||||||
|
import { Tier } from '../../types/api';
|
||||||
|
|
||||||
|
export interface TiersResponseType {
|
||||||
|
meta?: Meta
|
||||||
|
tiers: Tier[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataType = 'TiersResponseType';
|
||||||
|
|
||||||
|
export const useBrowseTiers = createQuery<TiersResponseType>({
|
||||||
|
dataType,
|
||||||
|
path: '/tiers/',
|
||||||
|
defaultSearchParams: {
|
||||||
|
limit: 'all'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useAddTier = createMutation<TiersResponseType, Partial<Tier>>({
|
||||||
|
method: 'POST',
|
||||||
|
path: () => '/tiers/',
|
||||||
|
body: tier => ({tiers: [tier]}),
|
||||||
|
// We may have queries for paid/archived/etc, so we can't assume how to update the global store and need to reload queries from the server
|
||||||
|
invalidateQueries: {dataType}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useEditTier = createMutation<TiersResponseType, Tier>({
|
||||||
|
method: 'PUT',
|
||||||
|
path: tier => `/tiers/${tier.id}/`,
|
||||||
|
body: tier => ({tiers: [tier]}),
|
||||||
|
updateQueries: {
|
||||||
|
dataType,
|
||||||
|
update: (newData, currentData) => ({
|
||||||
|
...(currentData as TiersResponseType),
|
||||||
|
tiers: (currentData as TiersResponseType).tiers.map((tier) => {
|
||||||
|
const newTier = newData.tiers.find(({id}) => id === tier.id);
|
||||||
|
return newTier || tier;
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
97
apps/admin-x-settings/src/utils/api/users.ts
Normal file
97
apps/admin-x-settings/src/utils/api/users.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { Meta, createMutation, createQuery } from '../apiRequests';
|
||||||
|
import { User } from '../../types/api';
|
||||||
|
|
||||||
|
export interface UsersResponseType {
|
||||||
|
meta?: Meta;
|
||||||
|
users: User[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdatePasswordOptions {
|
||||||
|
newPassword: string;
|
||||||
|
confirmNewPassword: string;
|
||||||
|
userId: string;
|
||||||
|
oldPassword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordUpdateResponseType {
|
||||||
|
password: [{
|
||||||
|
message: string;
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteUserResponse {
|
||||||
|
meta: {
|
||||||
|
filename: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataType = 'UsersResponseType';
|
||||||
|
|
||||||
|
const updateUsers = (newData: UsersResponseType, currentData: unknown) => ({
|
||||||
|
...(currentData as UsersResponseType),
|
||||||
|
users: (currentData as UsersResponseType).users.map((user) => {
|
||||||
|
const newUser = newData.users.find(({id}) => id === user.id);
|
||||||
|
return newUser || user;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useBrowseUsers = createQuery<UsersResponseType>({
|
||||||
|
dataType,
|
||||||
|
path: '/users/',
|
||||||
|
defaultSearchParams: {limit: 'all', include: 'roles'}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useCurrentUser = createQuery<User>({
|
||||||
|
dataType,
|
||||||
|
path: '/users/me/',
|
||||||
|
returnData: (originalData) => (originalData as UsersResponseType).users?.[0]
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useEditUser = createMutation<UsersResponseType, User>({
|
||||||
|
method: 'PUT',
|
||||||
|
path: user => `/users/${user.id}/`,
|
||||||
|
body: user => ({users: [user]}),
|
||||||
|
updateQueries: {
|
||||||
|
dataType,
|
||||||
|
update: updateUsers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useDeleteUser = createMutation<DeleteUserResponse, string>({
|
||||||
|
method: 'DELETE',
|
||||||
|
path: id => `/users/${id}/`,
|
||||||
|
updateQueries: {
|
||||||
|
dataType,
|
||||||
|
update: (_, currentData, id) => ({
|
||||||
|
...(currentData as UsersResponseType),
|
||||||
|
users: (currentData as UsersResponseType).users.filter(user => user.id !== id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useUpdatePassword = createMutation<PasswordUpdateResponseType, UpdatePasswordOptions>({
|
||||||
|
method: 'PUT',
|
||||||
|
path: () => '/users/password/',
|
||||||
|
body: ({newPassword, confirmNewPassword, userId, oldPassword}) => ({
|
||||||
|
password: [{
|
||||||
|
user_id: userId,
|
||||||
|
oldPassword: oldPassword || '',
|
||||||
|
newPassword: newPassword,
|
||||||
|
ne2Password: confirmNewPassword
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useMakeOwner = createMutation<UsersResponseType, string>({
|
||||||
|
method: 'PUT',
|
||||||
|
path: () => '/users/owner/',
|
||||||
|
body: userId => ({
|
||||||
|
owner: [{
|
||||||
|
id: userId
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
updateQueries: {
|
||||||
|
dataType,
|
||||||
|
update: updateUsers
|
||||||
|
}
|
||||||
|
});
|
140
apps/admin-x-settings/src/utils/apiRequests.ts
Normal file
140
apps/admin-x-settings/src/utils/apiRequests.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { QueryClient, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getGhostPaths } from './helpers';
|
||||||
|
import { useServices } from '../components/providers/ServiceProvider';
|
||||||
|
|
||||||
|
export interface Meta {
|
||||||
|
pagination: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
pages: number;
|
||||||
|
total: number;
|
||||||
|
next: number;
|
||||||
|
prev: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
method?: string;
|
||||||
|
body?: string | FormData;
|
||||||
|
headers?: {
|
||||||
|
'Content-Type'?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFetchApi = () => {
|
||||||
|
const {ghostVersion} = useServices();
|
||||||
|
|
||||||
|
return async (endpoint: string | URL, options: RequestOptions = {}) => {
|
||||||
|
// By default, we set the Content-Type header to application/json
|
||||||
|
const defaultHeaders = {
|
||||||
|
'app-pragma': 'no-cache',
|
||||||
|
'x-ghost-version': ghostVersion
|
||||||
|
};
|
||||||
|
const headers = options?.headers || {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
headers: {
|
||||||
|
...defaultHeaders,
|
||||||
|
...headers
|
||||||
|
},
|
||||||
|
method: 'GET',
|
||||||
|
mode: 'cors',
|
||||||
|
credentials: 'include',
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const {apiRoot} = getGhostPaths();
|
||||||
|
|
||||||
|
const apiUrl = (path: string, searchParams: { [key: string]: string } = {}) => {
|
||||||
|
const url = new URL(`${apiRoot}${path}`, window.location.origin);
|
||||||
|
url.search = new URLSearchParams(searchParams).toString();
|
||||||
|
return url.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const parameterizedPath = (path: string, params: string | string[]) => {
|
||||||
|
const paramList = Array.isArray(params) ? params : [params];
|
||||||
|
return paramList.reduce((updatedPath, param) => updatedPath.replace(/:[a-z0-9]+/, encodeURIComponent(param)), path);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface QueryOptions<ResponseData> {
|
||||||
|
dataType: string
|
||||||
|
path: string
|
||||||
|
defaultSearchParams?: { [key: string]: string };
|
||||||
|
returnData?: (originalData: unknown) => ResponseData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createQuery = <ResponseData>(options: QueryOptions<ResponseData>) => (searchParams?: { [key: string]: string }) => {
|
||||||
|
const url = apiUrl(options.path, searchParams || options.defaultSearchParams);
|
||||||
|
const fetchApi = useFetchApi();
|
||||||
|
|
||||||
|
const result = useQuery<ResponseData>({
|
||||||
|
queryKey: [options.dataType, url],
|
||||||
|
queryFn: () => fetchApi(url)
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: (result.data && options.returnData) ? options.returnData(result.data) : result.data
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createQueryWithId = <ResponseData>(options: QueryOptions<ResponseData>) => (id: string, searchParams?: { [key: string]: string }) => {
|
||||||
|
const queryHook = createQuery<ResponseData>({...options, path: parameterizedPath(options.path, id)});
|
||||||
|
return queryHook(searchParams || options.defaultSearchParams);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MutationOptions<ResponseData, Payload> extends Omit<QueryOptions<ResponseData>, 'dataType' | 'path'>, Omit<RequestOptions, 'body'> {
|
||||||
|
path: (payload: Payload) => string;
|
||||||
|
body?: (payload: Payload) => FormData | object;
|
||||||
|
searchParams?: (payload: Payload) => { [key: string]: string; };
|
||||||
|
invalidateQueries?: { dataType: string; };
|
||||||
|
updateQueries?: { dataType: string; update: <CurrentData>(newData: ResponseData, currentData: CurrentData, payload: Payload) => unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
const mutate = <ResponseData, Payload>({fetchApi, path, payload, searchParams, options}: {
|
||||||
|
fetchApi: ReturnType<typeof useFetchApi>;
|
||||||
|
path: string;
|
||||||
|
payload?: Payload;
|
||||||
|
searchParams?: { [key: string]: string };
|
||||||
|
options: MutationOptions<ResponseData, Payload>
|
||||||
|
}) => {
|
||||||
|
const {defaultSearchParams, body, ...requestOptions} = options;
|
||||||
|
const url = apiUrl(path, searchParams || defaultSearchParams);
|
||||||
|
console.log('api url', path, searchParams, url)
|
||||||
|
const generatedBody = payload && body?.(payload);
|
||||||
|
const requestBody = (generatedBody && generatedBody instanceof FormData) ? generatedBody : JSON.stringify(generatedBody)
|
||||||
|
|
||||||
|
return fetchApi(url, {
|
||||||
|
body: requestBody,
|
||||||
|
...requestOptions
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const afterMutate = <ResponseData, Payload>(newData: ResponseData, payload: Payload, queryClient: QueryClient, options: MutationOptions<ResponseData, Payload>) => {
|
||||||
|
if (options.invalidateQueries) {
|
||||||
|
queryClient.invalidateQueries([options.invalidateQueries.dataType]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.updateQueries) {
|
||||||
|
queryClient.setQueriesData([options.updateQueries.dataType], (data: unknown) => options.updateQueries!.update(newData, data, payload));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createMutation = <ResponseData, Payload>(options: MutationOptions<ResponseData, Payload>) => () => {
|
||||||
|
const fetchApi = useFetchApi();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<ResponseData, unknown, Payload>({
|
||||||
|
mutationFn: payload => mutate({fetchApi, path: options.path(payload), payload, searchParams: options.searchParams?.(payload) || options.defaultSearchParams, options}),
|
||||||
|
onSuccess: (newData, payload) => afterMutate(newData, payload, queryClient, options)
|
||||||
|
});
|
||||||
|
};
|
@ -1,64 +0,0 @@
|
|||||||
import {useEffect, useState} from 'react';
|
|
||||||
|
|
||||||
export interface DataService<Data> {
|
|
||||||
data: Data[];
|
|
||||||
update: (...data: Data[]) => Promise<void>;
|
|
||||||
create: (data: Partial<Data>) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
type BulkEditFunction<Data, DataKey extends string> = (newData: Data[]) => Promise<{ [k in DataKey]: Data[] }>
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
type AddFunction<Data, DataKey extends string> = (newData: Partial<Data>) => Promise<{ [k in DataKey]: Data[] }>
|
|
||||||
|
|
||||||
const useDataService = <Data extends { id: string }, DataKey extends string>({key, browse, edit, add}: {
|
|
||||||
key: DataKey
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
browse: () => Promise<{ [k in DataKey]: Data[] }>
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
edit: BulkEditFunction<Data, DataKey>
|
|
||||||
add: AddFunction<Data, DataKey>
|
|
||||||
}): DataService<Data> => {
|
|
||||||
const [data, setData] = useState<Data[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
browse().then((response) => {
|
|
||||||
setData(response[key]);
|
|
||||||
});
|
|
||||||
}, [browse, key]);
|
|
||||||
|
|
||||||
const update = async (...newData: Data[]) => {
|
|
||||||
const response = await edit(newData);
|
|
||||||
setData(data.map((item) => {
|
|
||||||
const replacement = response[key].find(newItem => newItem.id === item.id);
|
|
||||||
return replacement || item;
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const create = async (newData: Partial<Data>) => {
|
|
||||||
const response = await add(newData);
|
|
||||||
setData([...data, response[key][0]]);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {data, update, create};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useDataService;
|
|
||||||
|
|
||||||
// Utility for APIs which edit one object at a time
|
|
||||||
export const bulkEdit = <Data extends { id: string }, DataKey extends string>(
|
|
||||||
key: DataKey,
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
updateOne: (data: Data) => Promise<{ [k in DataKey]: Data[] }>
|
|
||||||
): BulkEditFunction<Data, DataKey> => {
|
|
||||||
return async (newData: Data[]) => {
|
|
||||||
const response = await Promise.all(newData.map(updateOne));
|
|
||||||
|
|
||||||
return {
|
|
||||||
[key]: response.reduce((all, current) => all.concat(current[key]), [] as Data[])
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
} as { [k in DataKey]: Data[] };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const placeholderDataService = {data: [], update: async () => {}, create: async () => {}};
|
|
@ -1,30 +1,9 @@
|
|||||||
import {expect, test} from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import {mockApi, responseFixtures} from '../../utils/e2e';
|
import { mockApi, responseFixtures } from '../../utils/e2e';
|
||||||
|
|
||||||
test.describe('Tier settings', async () => {
|
test.describe('Tier settings', async () => {
|
||||||
test('Supports creating a new tier', async ({page}) => {
|
test('Supports creating a new tier', async ({page}) => {
|
||||||
const lastApiRequests = await mockApi({page, responses: {
|
await mockApi({page});
|
||||||
tiers: {
|
|
||||||
add: {
|
|
||||||
tiers: [{
|
|
||||||
id: 'new-tier',
|
|
||||||
type: 'paid',
|
|
||||||
active: true,
|
|
||||||
name: 'Plus tier',
|
|
||||||
slug: 'plus-tier',
|
|
||||||
description: null,
|
|
||||||
monthly_price: 800,
|
|
||||||
yearly_price: 8000,
|
|
||||||
benefits: [],
|
|
||||||
welcome_page_url: null,
|
|
||||||
trial_days: 0,
|
|
||||||
visibility: 'public',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString()
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}});
|
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
|
|
||||||
@ -44,6 +23,31 @@ test.describe('Tier settings', async () => {
|
|||||||
await modal.getByLabel('Monthly price').fill('8');
|
await modal.getByLabel('Monthly price').fill('8');
|
||||||
await modal.getByLabel('Yearly price').fill('80');
|
await modal.getByLabel('Yearly price').fill('80');
|
||||||
|
|
||||||
|
const newTier = {
|
||||||
|
id: 'new-tier',
|
||||||
|
type: 'paid',
|
||||||
|
active: true,
|
||||||
|
name: 'Plus tier',
|
||||||
|
slug: 'plus-tier',
|
||||||
|
description: null,
|
||||||
|
monthly_price: 800,
|
||||||
|
yearly_price: 8000,
|
||||||
|
benefits: [],
|
||||||
|
welcome_page_url: null,
|
||||||
|
trial_days: 0,
|
||||||
|
visibility: 'public',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastApiRequests = await mockApi({page, responses: {
|
||||||
|
tiers: {
|
||||||
|
add: { tiers: [newTier] },
|
||||||
|
// This request will be reloaded after the new tier is added
|
||||||
|
browse: { tiers: [...responseFixtures.tiers.tiers, newTier] }
|
||||||
|
}
|
||||||
|
}});
|
||||||
|
|
||||||
await modal.getByRole('button', {name: 'Save & close'}).click();
|
await modal.getByRole('button', {name: 'Save & close'}).click();
|
||||||
|
|
||||||
await expect(section.getByTestId('tier-card').filter({hasText: /Plus/})).toHaveText(/Plus tier/);
|
await expect(section.getByTestId('tier-card').filter({hasText: /Plus/})).toHaveText(/Plus tier/);
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {expect, test} from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import {mockApi, responseFixtures} from '../../utils/e2e';
|
import { mockApi, responseFixtures } from '../../utils/e2e';
|
||||||
|
|
||||||
test.describe('Theme settings', async () => {
|
test.describe('Theme settings', async () => {
|
||||||
test('Browsing and installing default themes', async ({page}) => {
|
test('Browsing and installing default themes', async ({page}) => {
|
||||||
@ -36,7 +36,7 @@ test.describe('Theme settings', async () => {
|
|||||||
|
|
||||||
const modal = page.getByTestId('theme-modal');
|
const modal = page.getByTestId('theme-modal');
|
||||||
|
|
||||||
// // The default theme is always considered "installed"
|
// The default theme is always considered "installed"
|
||||||
|
|
||||||
await modal.getByRole('button', {name: /Casper/}).click();
|
await modal.getByRole('button', {name: /Casper/}).click();
|
||||||
|
|
||||||
|
@ -1,6 +1,18 @@
|
|||||||
import {ConfigResponseType, CustomThemeSettingsResponseType, ImagesResponseType, InvitesResponseType, LabelsResponseType, OffersResponseType, PostsResponseType, RolesResponseType, SettingsResponseType, SiteResponseType, ThemesResponseType, TiersResponseType, UsersResponseType} from '../../src/utils/api';
|
import { ConfigResponseType } from '../../src/utils/api/config'
|
||||||
import {Page, Request} from '@playwright/test';
|
import { CustomThemeSettingsResponseType } from '../../src/utils/api/customThemeSettings'
|
||||||
import {readFileSync} from 'fs';
|
import { ImagesResponseType } from '../../src/utils/api/images'
|
||||||
|
import { InvitesResponseType } from '../../src/utils/api/invites'
|
||||||
|
import { LabelsResponseType } from '../../src/utils/api/labels'
|
||||||
|
import { OffersResponseType } from '../../src/utils/api/offers'
|
||||||
|
import { Page, Request } from '@playwright/test'
|
||||||
|
import { PostsResponseType } from '../../src/utils/api/posts'
|
||||||
|
import { RolesResponseType } from '../../src/utils/api/roles'
|
||||||
|
import { SettingsResponseType } from '../../src/utils/api/settings'
|
||||||
|
import { SiteResponseType } from '../../src/utils/api/site'
|
||||||
|
import { ThemesResponseType } from '../../src/utils/api/themes'
|
||||||
|
import { TiersResponseType } from '../../src/utils/api/tiers'
|
||||||
|
import { UsersResponseType } from '../../src/utils/api/users'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
|
||||||
export const responseFixtures = {
|
export const responseFixtures = {
|
||||||
settings: JSON.parse(readFileSync(`${__dirname}/responses/settings.json`).toString()) as SettingsResponseType,
|
settings: JSON.parse(readFileSync(`${__dirname}/responses/settings.json`).toString()) as SettingsResponseType,
|
||||||
@ -80,7 +92,7 @@ interface Responses {
|
|||||||
|
|
||||||
interface RequestRecord {
|
interface RequestRecord {
|
||||||
url?: string
|
url?: string
|
||||||
body?: any
|
body?: object | null
|
||||||
headers?: {[key: string]: string}
|
headers?: {[key: string]: string}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -472,7 +484,7 @@ export async function mockApi({page,responses}: {page: Page, responses?: Respons
|
|||||||
|
|
||||||
interface ResponseOptions {
|
interface ResponseOptions {
|
||||||
condition?: (request: Request) => boolean
|
condition?: (request: Request) => boolean
|
||||||
body: any
|
body: object | string
|
||||||
status?: number
|
status?: number
|
||||||
updateLastRequest: RequestRecord
|
updateLastRequest: RequestRecord
|
||||||
}
|
}
|
||||||
@ -492,7 +504,7 @@ async function mockApiResponse({page, path, respondTo}: { page: Page; path: stri
|
|||||||
|
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
status: response.status || 200,
|
status: response.status || 200,
|
||||||
body: JSON.stringify(response.body)
|
body: typeof response.body === 'string' ? response.body : JSON.stringify(response.body)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
18
yarn.lock
18
yarn.lock
@ -5818,6 +5818,19 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz#767cf8e5d528a5d90c9740ca66eb079f5e87d423"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/line-clamp/-/line-clamp-0.4.4.tgz#767cf8e5d528a5d90c9740ca66eb079f5e87d423"
|
||||||
integrity sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==
|
integrity sha512-5U6SY5z8N42VtrCrKlsTAA35gy2VSyYtHWCsg1H87NU1SXnEfekTVlrga9fzUDrrHcGi2Lb5KenUWb4lRQT5/g==
|
||||||
|
|
||||||
|
"@tanstack/query-core@4.29.25":
|
||||||
|
version "4.29.25"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.29.25.tgz#605d357968a740544af6754004eed1dfd4587cb8"
|
||||||
|
integrity sha512-DI4y4VC6Uw4wlTpOocEXDky69xeOScME1ezLKsj+hOk7DguC9fkqXtp6Hn39BVb9y0b5IBrY67q6kIX623Zj4Q==
|
||||||
|
|
||||||
|
"@tanstack/react-query@4.29.25":
|
||||||
|
version "4.29.25"
|
||||||
|
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.29.25.tgz#64df3260b65760fbd3c81ffae23b7b3802c71aa6"
|
||||||
|
integrity sha512-c1+Ezu+XboYrdAMdusK2fTdRqXPMgPAnyoTrzHOZQqr8Hqz6PNvV9DSKl8agUo6nXX4np7fdWabIprt+838dLg==
|
||||||
|
dependencies:
|
||||||
|
"@tanstack/query-core" "4.29.25"
|
||||||
|
use-sync-external-store "^1.2.0"
|
||||||
|
|
||||||
"@testing-library/dom@^8.0.0":
|
"@testing-library/dom@^8.0.0":
|
||||||
version "8.20.0"
|
version "8.20.0"
|
||||||
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.0.tgz#914aa862cef0f5e89b98cc48e3445c4c921010f6"
|
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.20.0.tgz#914aa862cef0f5e89b98cc48e3445c4c921010f6"
|
||||||
@ -28951,6 +28964,11 @@ use-resize-observer@^9.1.0:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@juggle/resize-observer" "^3.3.1"
|
"@juggle/resize-observer" "^3.3.1"
|
||||||
|
|
||||||
|
use-sync-external-store@^1.2.0:
|
||||||
|
version "1.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
|
||||||
|
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
|
||||||
|
|
||||||
use@^3.1.0:
|
use@^3.1.0:
|
||||||
version "3.1.1"
|
version "3.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
|
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
|
||||||
|
Loading…
Reference in New Issue
Block a user